diff --git a/conftest.py b/conftest.py index 8166295..cd04095 100644 --- a/conftest.py +++ b/conftest.py @@ -1,69 +1,67 @@ +from __future__ import annotations + import logging -import pytest from os.path import splitext +from typing import TYPE_CHECKING, Generator, Optional, cast + +import pytest + from tests.utils.gen_utils import GenUtils +if TYPE_CHECKING: + from pluggy import Result # runtime-optional (pluggy < 1.2), only needed for typing + logger: logging.Logger = logging.getLogger("conftest") +_NOT_SUPPORTED_HINTS: tuple[str, ...] = ("not supported", "does not support", "doesn't support") -def pytest_runtest_logreport(report: pytest.TestReport) -> None: - if report.outcome != "rerun": # type: ignore - pytest-rerunfailures sets this outcome - return - message: str +def _crash_message(report: pytest.TestReport) -> str: + reprcrash: object = getattr(report.longrepr, "reprcrash", None) + message: Optional[str] = getattr(reprcrash, "message", None) + return str(message) if message else (report.longreprtext or "unknown") - try: - crash_msg = report.longrepr.reprcrash.message # type: ignore - message = str(crash_msg) if crash_msg is not None else "" # type: ignore - except AttributeError: - message = report.longreprtext - except Exception as e: - message = str(e) - if len(message) == 0: - message = "Unknwon" - - logger.error(f"EXPECTED FAILURE: {message}") +def pytest_runtest_logreport(report: pytest.TestReport) -> None: + if report.outcome != "rerun": # type: ignore # set by pytest-rerunfailures + return + logger.error(f"EXPECTED FAILURE: {_crash_message(report)}") logger.warning(f"RERUN {report.nodeid}") def pytest_configure(config: pytest.Config) -> None: - # inject current date/time into the configured log file name - log_file: str = config.getini("log_file") # type: ignore - + # timestamp the log file so each run keeps its own + log_file: str = cast(str, config.getini("log_file")) if not log_file: return - name, ext = splitext(log_file) config.option.log_file = f"{name}_{GenUtils.current_date_time_str()}{ext}" -def pytest_runtest_call(item: pytest.Item): - # get not_supported marker - marker: pytest.Mark | None = item.get_closest_marker("not_supported") - not_implemented: bool = False +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + # `not_implemented` == non-strict xfail: xfails while it raises, xpasses once done + for item in items: + if item.get_closest_marker("not_implemented") is not None: + item.add_marker(pytest.mark.xfail(reason="not implemented", strict=False)) - if marker is None: - # get not_implemented marker - marker = item.get_closest_marker("not_implemented") - not_implemented = True - if marker is None: - # marker not found +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_call(item: pytest.Item) -> Generator[None, Result[None], None]: + # `not_supported`: the inherited test must raise a "not supported" error; + # that's a pass, anything else (or no error) is a failure. + if item.get_closest_marker("not_supported") is None: + yield return + outcome: Result[None] = yield + try: - # run test - item.runtest() - except Exception as e: - e_str = str(e).lower() - if "not supported" in e_str or "does not support" in e_str or "doesn't support" in e_str: - # Ok - pytest.xfail(str(e)) - if not_implemented and "not implemented" in e_str: - pytest.xfail(str(e)) - raise - else: - # fail test - pytest.fail("Expected test to fail") + outcome.get_result() + except Exception as error: + if any(hint in str(error).lower() for hint in _NOT_SUPPORTED_HINTS): + logger.debug(f"NOT SUPPORTED (as expected): {error}") + outcome.force_result(None) + return + + outcome.force_exception(pytest.fail.Exception("Expected a 'not supported' error")) diff --git a/external/monero-cpp b/external/monero-cpp index 4b286a5..8db4afa 160000 --- a/external/monero-cpp +++ b/external/monero-cpp @@ -1 +1 @@ -Subproject commit 4b286a53eecdd1e39dd5bf1442690c7915f145d0 +Subproject commit 8db4afab28f394f3dc78de6b4c2d3473484ce1b9 diff --git a/pytest.ini b/pytest.ini index 752cff6..aedddee 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,7 @@ [pytest] minversion = 6.0 required_plugins = pytest-rerunfailures pytest-timeout pytest-cov -addopts = -s -v --reruns 5 --reruns-delay 10 --only-rerun "BUSY" +addopts = -s -v --reruns 5 --reruns-delay 10 --only-rerun "Daemon is busy" log_level = INFO log_cli = True log_cli_level = INFO @@ -15,5 +15,5 @@ testpaths = markers = unit: fast unit tests, no external dependencies integration: slow integration tests, requires external services - not_supported: expects not supported error - not_implemented: expects not implemented error + not_supported: inherited test whose operation is permanently unsupported for this type; passes when it raises a "not supported" error, fails otherwise + not_implemented: inherited test whose operation is not implemented yet; applied as a non-strict xfail (xfails while it raises, xpasses once the feature lands) diff --git a/src/cpp/daemon/py_monero_daemon.h b/src/cpp/daemon/py_monero_daemon.h index ba4a00a..bdf1abb 100644 --- a/src/cpp/daemon/py_monero_daemon.h +++ b/src/cpp/daemon/py_monero_daemon.h @@ -109,6 +109,18 @@ class PyMoneroDaemon : public monero_daemon { PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, get_block_template, wallet_address, reserve_size); } + std::shared_ptr get_miner_data() override { + PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, get_miner_data); + } + + std::string calculate_pow(uint32_t major_version, uint64_t height, const std::string& block_blob, const std::string& seed_hash) override { + PYBIND11_OVERRIDE(std::string, monero_daemon, calculate_pow, major_version, height, block_blob, seed_hash); + } + + std::shared_ptr add_auxiliary_pow(const std::string& block_template_blob, const std::vector>& aux_pow) override { + PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, add_auxiliary_pow, block_template_blob, aux_pow); + } + std::shared_ptr get_last_block_header() override { PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, get_last_block_header); } @@ -129,8 +141,8 @@ class PyMoneroDaemon : public monero_daemon { PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, get_block_by_hash, hash); } - std::vector> get_blocks_by_hash(const std::vector& block_hashes, uint64_t start_height, bool prune) override { - PYBIND11_OVERRIDE(std::vector>, monero_daemon, get_blocks_by_hash, block_hashes, start_height, prune); + std::shared_ptr get_blocks_by_hash(const std::vector& block_hashes, uint64_t start_height, bool prune, uint64_t max_block_count = 0) override { + PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, get_blocks_by_hash, block_hashes, start_height, prune, max_block_count); } std::shared_ptr get_block_by_height(uint64_t height) override { @@ -149,8 +161,8 @@ class PyMoneroDaemon : public monero_daemon { PYBIND11_OVERRIDE(std::vector>, monero_daemon, get_blocks_by_range_chunked, start_height, end_height, max_chunk_size); } - std::vector get_block_hashes(const std::vector& block_hashes, uint64_t start_height) override { - PYBIND11_OVERRIDE(std::vector, monero_daemon, get_block_hashes, block_hashes, start_height); + std::shared_ptr get_block_hashes(const std::vector& block_hashes) override { + PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, get_block_hashes, block_hashes); } std::shared_ptr get_tx(const std::string& tx_hash, bool prune = false) override { @@ -225,6 +237,10 @@ class PyMoneroDaemon : public monero_daemon { PYBIND11_OVERRIDE(std::vector, monero_daemon, get_key_image_spent_statuses, key_images); } + std::vector get_output_indices(const std::string& tx_hash) override { + PYBIND11_OVERRIDE(std::vector, monero_daemon, get_output_indices, tx_hash); + } + std::vector> get_outputs(const std::vector& outputs) override { PYBIND11_OVERRIDE(std::vector>, monero_daemon, get_outputs, outputs); } @@ -245,6 +261,10 @@ class PyMoneroDaemon : public monero_daemon { PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, get_sync_info); } + std::shared_ptr get_network_stats() override { + PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, get_network_stats); + } + std::shared_ptr get_hard_fork_info() override { PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, get_hard_fork_info); } @@ -289,6 +309,10 @@ class PyMoneroDaemon : public monero_daemon { PYBIND11_OVERRIDE(std::vector>, monero_daemon, get_known_peers); } + std::vector> get_public_peers(bool include_offline = false) override { + PYBIND11_OVERRIDE(std::vector>, monero_daemon, get_public_peers, include_offline); + } + void set_outgoing_peer_limit(int limit) override { PYBIND11_OVERRIDE(void, monero_daemon, set_outgoing_peer_limit, limit); } @@ -309,6 +333,10 @@ class PyMoneroDaemon : public monero_daemon { PYBIND11_OVERRIDE(void, monero_daemon, set_peer_ban, ban); } + std::shared_ptr get_peer_ban(const std::string& address) override { + PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, get_peer_ban, address); + } + void start_mining(const std::string &address, boost::optional num_threads, boost::optional is_background, boost::optional ignore_battery) override { PYBIND11_OVERRIDE(void, monero_daemon, start_mining, address, num_threads, is_background, ignore_battery); } @@ -337,6 +365,34 @@ class PyMoneroDaemon : public monero_daemon { PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, prune_blockchain, check); } + void save_blockchain() override { + PYBIND11_OVERRIDE(void, monero_daemon, save_blockchain); + } + + uint64_t pop_blocks(uint64_t num_blocks) override { + PYBIND11_OVERRIDE(uint64_t, monero_daemon, pop_blocks, num_blocks); + } + + void flush_cache(bool bad_blocks = false) override { + PYBIND11_OVERRIDE(void, monero_daemon, flush_cache, bad_blocks); + } + + void set_bootstrap_daemon(const std::string& address, const std::string& username = "", const std::string& password = "", const std::string& proxy = "") override { + PYBIND11_OVERRIDE(void, monero_daemon, set_bootstrap_daemon, address, username, password, proxy); + } + + void set_log_hash_rate(bool is_visible) override { + PYBIND11_OVERRIDE(void, monero_daemon, set_log_hash_rate, is_visible); + } + + void set_log_level(int level) override { + PYBIND11_OVERRIDE(void, monero_daemon, set_log_level, level); + } + + std::string set_log_categories(const std::string& categories = "") override { + PYBIND11_OVERRIDE(std::string, monero_daemon, set_log_categories, categories); + } + std::shared_ptr check_for_update() override { PYBIND11_OVERRIDE(std::shared_ptr, monero_daemon, check_for_update); } diff --git a/src/cpp/daemon/py_monero_daemon_bindings.cpp b/src/cpp/daemon/py_monero_daemon_bindings.cpp index 26ec16e..eb0cc19 100644 --- a/src/cpp/daemon/py_monero_daemon_bindings.cpp +++ b/src/cpp/daemon/py_monero_daemon_bindings.cpp @@ -251,6 +251,74 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def_readwrite("window", &monero_hard_fork_info::m_window) .def_readwrite("voting", &monero_hard_fork_info::m_voting); + // monero_daemon_network_stats + py::class_>(m, "MoneroDaemonNetworkStats") + .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) + .def_readwrite("start_time", &monero_daemon_network_stats::m_start_time) + .def_readwrite("total_packets_in", &monero_daemon_network_stats::m_total_packets_in) + .def_readwrite("total_bytes_in", &monero_daemon_network_stats::m_total_bytes_in) + .def_readwrite("total_packets_out", &monero_daemon_network_stats::m_total_packets_out) + .def_readwrite("total_bytes_out", &monero_daemon_network_stats::m_total_bytes_out); + + // monero_auxiliary_pow + py::class_>(m, "MoneroAuxiliaryPow") + .def(py::init<>()) + .def(py::init(), py::arg("id"), py::arg("hash")) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) + .def_readwrite("id", &monero_auxiliary_pow::m_id) + .def_readwrite("hash", &monero_auxiliary_pow::m_hash); + + // monero_add_auxiliary_pow_result + py::class_>(m, "MoneroAddAuxiliaryPowResult") + .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) + .def_readwrite("block_template_blob", &monero_add_auxiliary_pow_result::m_block_template_blob) + .def_readwrite("block_hashing_blob", &monero_add_auxiliary_pow_result::m_block_hashing_blob) + .def_readwrite("merkle_root", &monero_add_auxiliary_pow_result::m_merkle_root) + .def_readwrite("merkle_tree_depth", &monero_add_auxiliary_pow_result::m_merkle_tree_depth) + .def_readwrite("aux_pow", &monero_add_auxiliary_pow_result::m_aux_pow); + + // monero_miner_data + py::class_>(m, "MoneroMinerData") + .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) + .def_readwrite("major_version", &monero_miner_data::m_major_version) + .def_readwrite("height", &monero_miner_data::m_height) + .def_readwrite("prev_hash", &monero_miner_data::m_prev_hash) + .def_readwrite("seed_hash", &monero_miner_data::m_seed_hash) + .def_readwrite("difficulty", &monero_miner_data::m_difficulty) + .def_readwrite("median_weight", &monero_miner_data::m_median_weight) + .def_readwrite("already_generated_coins", &monero_miner_data::m_already_generated_coins) + .def_readwrite("tx_pool_backlog", &monero_miner_data::m_tx_pool_backlog); + + // monero_get_blocks_by_hash_result + py::class_>(m, "MoneroGetBlocksByHashResult") + .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) + .def_readwrite("blocks", &monero_get_blocks_by_hash_result::m_blocks) + .def_readwrite("current_height", &monero_get_blocks_by_hash_result::m_current_height); + + // monero_get_block_hashes_result + py::class_>(m, "MoneroGetBlockHashesResult") + .def(py::init<>()) + .def_static("deserialize", [](const std::string& json) { + MONERO_CATCH_AND_RETHROW(py_monero_deserialize(json)); + }, py::arg("json")) + .def_readwrite("hashes", &monero_get_block_hashes_result::m_hashes) + .def_readwrite("start_height", &monero_get_block_hashes_result::m_start_height) + .def_readwrite("current_height", &monero_get_block_hashes_result::m_current_height); + // monero_prune_result py::class_>(m, "MoneroPruneResult") .def(py::init<>()) @@ -311,7 +379,8 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def_readwrite("update_available", &monero_daemon_info::m_update_available) .def_readwrite("is_busy_syncing", &monero_daemon_info::m_is_busy_syncing) .def_readwrite("is_synchronized", &monero_daemon_info::m_is_synchronized) - .def_readwrite("is_restricted", &monero_daemon_info::m_is_restricted); + .def_readwrite("is_restricted", &monero_daemon_info::m_is_restricted) + .def_readwrite("is_regtest", &monero_daemon_info::m_is_regtest); // monero_daemon_update_check_result py::class_>(m, "MoneroDaemonUpdateCheckResult") @@ -424,6 +493,7 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def_readwrite("is_relayed", &monero_tx::m_is_relayed) .def_readwrite("is_confirmed", &monero_tx::m_is_confirmed) .def_readwrite("in_tx_pool", &monero_tx::m_in_tx_pool) + .def_readwrite("is_locked", &monero_tx::m_is_locked) .def_readwrite("num_confirmations", &monero_tx::m_num_confirmations) .def_readwrite("unlock_time", &monero_tx::m_unlock_time) .def_readwrite("last_relayed_timestamp", &monero_tx::m_last_relayed_timestamp) @@ -496,6 +566,7 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def_readwrite("amount", &monero_output::m_amount) .def_readwrite("index", &monero_output::m_index) .def_readwrite("stealth_public_key", &monero_output::m_stealth_public_key) + .def_readwrite("mask", &monero_output::m_mask) .def_readwrite("ring_output_indices", &monero_output::m_ring_output_indices) .def("copy", [](const std::shared_ptr& self) { auto tgt = std::make_shared(); @@ -522,6 +593,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def("remove_listener", [](monero_daemon& self, monero_daemon_listener& listener) { MONERO_CATCH_AND_RETHROW(self.remove_listener(listener)); }, py::arg("listener"), py::call_guard()) + .def("remove_listeners", [](monero_daemon& self) { + MONERO_CATCH_AND_RETHROW(self.remove_listeners()); + }, py::call_guard()) .def("get_listeners", [](monero_daemon& self) { MONERO_CATCH_AND_RETHROW(self.get_listeners()); }, py::call_guard()) @@ -540,6 +614,15 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def("get_block_template", [](monero_daemon& self, const std::string& wallet_address, const boost::optional& reserve_size) { MONERO_CATCH_AND_RETHROW(self.get_block_template(wallet_address, reserve_size)); }, py::arg("wallet_address"), py::arg("reserve_size") = py::none(), py::call_guard()) + .def("get_miner_data", [](monero_daemon& self) { + MONERO_CATCH_AND_RETHROW(self.get_miner_data()); + }, py::call_guard()) + .def("calculate_pow", [](monero_daemon& self, uint32_t major_version, uint64_t height, const std::string& block_blob, const std::string& seed_hash) { + MONERO_CATCH_AND_RETHROW(self.calculate_pow(major_version, height, block_blob, seed_hash)); + }, py::arg("major_version"), py::arg("height"), py::arg("block_blob"), py::arg("seed_hash"), py::call_guard()) + .def("add_auxiliary_pow", [](monero_daemon& self, const std::string& block_template_blob, const std::vector>& aux_pow) { + MONERO_CATCH_AND_RETHROW(self.add_auxiliary_pow(block_template_blob, aux_pow)); + }, py::arg("block_template_blob"), py::arg("aux_pow"), py::call_guard()) .def("get_last_block_header", [](monero_daemon& self) { MONERO_CATCH_AND_RETHROW(self.get_last_block_header()); }, py::call_guard()) @@ -555,9 +638,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def("get_block_by_hash", [](monero_daemon& self, const std::string& hash) { MONERO_CATCH_AND_RETHROW(self.get_block_by_hash(hash)); }, py::arg("hash"), py::call_guard()) - .def("get_blocks_by_hash", [](monero_daemon& self, const std::vector& block_hashes, uint64_t start_height, bool prune) { - MONERO_CATCH_AND_RETHROW(self.get_blocks_by_hash(block_hashes, start_height, prune)); - }, py::arg("block_hashes"), py::arg("start_height"), py::arg("prune"), py::call_guard()) + .def("get_blocks_by_hash", [](monero_daemon& self, const std::vector& block_hashes, uint64_t start_height, bool prune, uint64_t max_block_count) { + MONERO_CATCH_AND_RETHROW(self.get_blocks_by_hash(block_hashes, start_height, prune, max_block_count)); + }, py::arg("block_hashes"), py::arg("start_height"), py::arg("prune"), py::arg("max_block_count") = 0, py::call_guard()) .def("get_block_by_height", [](monero_daemon& self, uint64_t height) { MONERO_CATCH_AND_RETHROW(self.get_block_by_height(height)); }, py::arg("height"), py::call_guard()) @@ -570,9 +653,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def("get_blocks_by_range_chunked", [](monero_daemon& self, const boost::optional& start_height, const boost::optional& end_height, const boost::optional& max_chunk_size) { MONERO_CATCH_AND_RETHROW(self.get_blocks_by_range_chunked(start_height, end_height, max_chunk_size)); }, py::arg("start_height"), py::arg("end_height"), py::arg("max_chunk_size") = py::none(), py::call_guard()) - .def("get_block_hashes", [](monero_daemon& self, const std::vector& block_hashes, uint64_t start_height) { - MONERO_CATCH_AND_RETHROW(self.get_block_hashes(block_hashes, start_height)); - }, py::arg("block_hashes"), py::arg("start_height"), py::call_guard()) + .def("get_block_hashes", [](monero_daemon& self, const std::vector& block_hashes) { + MONERO_CATCH_AND_RETHROW(self.get_block_hashes(block_hashes)); + }, py::arg("block_hashes"), py::call_guard()) .def("get_tx", [](monero_daemon& self, const std::string& tx_hash, bool prune) { MONERO_CATCH_AND_RETHROW(self.get_tx(tx_hash, prune)); }, py::arg("tx_hash"), py::arg("prune") = false, py::call_guard()) @@ -627,6 +710,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def("get_key_image_spent_statuses", [](monero_daemon& self, const std::vector& key_images) { MONERO_CATCH_AND_RETHROW(self.get_key_image_spent_statuses(key_images)); }, py::arg("key_images"), py::call_guard()) + .def("get_output_indices", [](monero_daemon& self, const std::string& tx_hash) { + MONERO_CATCH_AND_RETHROW(self.get_output_indices(tx_hash)); + }, py::arg("tx_hash"), py::call_guard()) .def("get_outputs", [](monero_daemon& self, const std::vector& outputs) { MONERO_CATCH_AND_RETHROW(self.get_outputs(outputs)); }, py::arg("outputs"), py::call_guard()) @@ -642,6 +728,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def("get_sync_info", [](monero_daemon& self) { MONERO_CATCH_AND_RETHROW(self.get_sync_info()); }, py::call_guard()) + .def("get_network_stats", [](monero_daemon& self) { + MONERO_CATCH_AND_RETHROW(self.get_network_stats()); + }, py::call_guard()) .def("get_hard_fork_info", [](monero_daemon& self) { MONERO_CATCH_AND_RETHROW(self.get_hard_fork_info()); }, py::call_guard()) @@ -675,6 +764,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def("get_known_peers", [](monero_daemon& self) { MONERO_CATCH_AND_RETHROW(self.get_known_peers()); }, py::call_guard()) + .def("get_public_peers", [](monero_daemon& self, bool include_offline) { + MONERO_CATCH_AND_RETHROW(self.get_public_peers(include_offline)); + }, py::arg("include_offline") = false, py::call_guard()) .def("set_outgoing_peer_limit", [](monero_daemon& self, int limit) { MONERO_CATCH_AND_RETHROW(self.set_outgoing_peer_limit(limit)); }, py::arg("limit"), py::call_guard()) @@ -690,6 +782,9 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def("set_peer_ban", [](monero_daemon& self, const std::shared_ptr& ban) { MONERO_CATCH_AND_RETHROW(self.set_peer_ban(ban)); }, py::arg("ban"), py::call_guard()) + .def("get_peer_ban", [](monero_daemon& self, const std::string& address) { + MONERO_CATCH_AND_RETHROW(self.get_peer_ban(address)); + }, py::arg("address"), py::call_guard()) .def("start_mining", [](monero_daemon& self, const std::string& address, const boost::optional& num_threads, const boost::optional& is_background, const boost::optional& ignore_battery) { MONERO_CATCH_AND_RETHROW(self.start_mining(address, num_threads, is_background, ignore_battery)); }, py::arg("address"), py::arg("num_threads"), py::arg("is_background"), py::arg("ignore_battery"), py::call_guard()) @@ -711,6 +806,30 @@ void py_monero_bind_daemon(py::module_& m, PyMoneroTypes& t) { .def("prune_blockchain", [](monero_daemon& self, bool check) { MONERO_CATCH_AND_RETHROW(self.prune_blockchain(check)); }, py::arg("check"), py::call_guard()) + .def("save_blockchain", [](monero_daemon& self) { + MONERO_CATCH_AND_RETHROW(self.save_blockchain()); + }, py::call_guard()) + .def("pop_blocks", [](monero_daemon& self, uint64_t num_blocks) { + MONERO_CATCH_AND_RETHROW(self.pop_blocks(num_blocks)); + }, py::arg("num_blocks"), py::call_guard()) + .def("flush_cache", [](monero_daemon& self, bool bad_blocks) { + MONERO_CATCH_AND_RETHROW(self.flush_cache(bad_blocks)); + }, py::arg("bad_blocks") = false, py::call_guard()) + .def("set_bootstrap_daemon", [](monero_daemon& self, const std::string& address, const std::string& username, const std::string& password, const std::string& proxy) { + MONERO_CATCH_AND_RETHROW(self.set_bootstrap_daemon(address, username, password, proxy)); + }, py::arg("address"), py::arg("username") = "", py::arg("password") = "", py::arg("proxy") = "", py::call_guard()) + .def("remove_bootstrap_daemon", [](monero_daemon& self) { + MONERO_CATCH_AND_RETHROW(self.remove_bootstrap_daemon()); + }, py::call_guard()) + .def("set_log_hash_rate", [](monero_daemon& self, bool is_visible) { + MONERO_CATCH_AND_RETHROW(self.set_log_hash_rate(is_visible)); + }, py::arg("is_visible"), py::call_guard()) + .def("set_log_level", [](monero_daemon& self, int level) { + MONERO_CATCH_AND_RETHROW(self.set_log_level(level)); + }, py::arg("level"), py::call_guard()) + .def("set_log_categories", [](monero_daemon& self, const std::string& categories) { + MONERO_CATCH_AND_RETHROW(self.set_log_categories(categories)); + }, py::arg("categories") = "", py::call_guard()) .def("check_for_update", [](monero_daemon& self) { MONERO_CATCH_AND_RETHROW(self.check_for_update()); }, py::call_guard()) diff --git a/src/cpp/py_monero_types.h b/src/cpp/py_monero_types.h index e748894..7f54090 100644 --- a/src/cpp/py_monero_types.h +++ b/src/cpp/py_monero_types.h @@ -53,6 +53,23 @@ */ #pragma once +// Opaque STL container bindings must be declared before any translation unit +#include +#include +#include +#include +#include + +using VectorInt = std::vector; +using VectorUint8 = std::vector; +using VectorUint32 = std::vector; +using VectorUint64 = std::vector; + +PYBIND11_MAKE_OPAQUE(VectorInt); +PYBIND11_MAKE_OPAQUE(VectorUint8); +PYBIND11_MAKE_OPAQUE(VectorUint32); +PYBIND11_MAKE_OPAQUE(VectorUint64); + #include "common/monero_error.h" #include "daemon/py_monero_daemon.h" #include "daemon/monero_daemon_rpc.h" @@ -90,10 +107,6 @@ inline std::shared_ptr py_monero_deserialize_rpc_connecti throw monero_error(e.what()); \ } -using VectorInt = std::vector; -using VectorUint8 = std::vector; -using VectorUint32 = std::vector; -using VectorUint64 = std::vector; using VectorString = std::vector; using VectorMoneroOutgoingTransfer = std::vector>; @@ -104,11 +117,6 @@ using VectorMoneroSubaddress = std::vector; using VectorMoneroDestination = std::vector>; -PYBIND11_MAKE_OPAQUE(VectorInt); -PYBIND11_MAKE_OPAQUE(VectorUint8); -PYBIND11_MAKE_OPAQUE(VectorUint32); -PYBIND11_MAKE_OPAQUE(VectorUint64); - /** * Holds every pybind11 type handle that must be registered before any * method is bound, so classes can reference each other (as base classes diff --git a/src/cpp/utils/py_monero_utils.cpp b/src/cpp/utils/py_monero_utils.cpp index 971a3ce..04f6a3b 100644 --- a/src/cpp/utils/py_monero_utils.cpp +++ b/src/cpp/utils/py_monero_utils.cpp @@ -84,6 +84,12 @@ std::string PyMoneroUtils::binary_blocks_to_json(const std::string &bin) { return json; } +std::string PyMoneroUtils::binary_blocks_fast_to_json(const std::string &bin) { + std::string json; + monero_utils::binary_blocks_fast_to_json(bin, json); + return json; +} + void PyMoneroUtils::sort_txs_wallet(std::vector>& txs, const std::vector& hashes) { bool empty = hashes.empty(); std::vector tx_hashes; diff --git a/src/cpp/utils/py_monero_utils.h b/src/cpp/utils/py_monero_utils.h index e150b68..52c2fe5 100644 --- a/src/cpp/utils/py_monero_utils.h +++ b/src/cpp/utils/py_monero_utils.h @@ -69,6 +69,7 @@ class PyMoneroUtils { static py::dict binary_to_dict(const std::string& bin); static std::string binary_to_json(const std::string &bin); static std::string binary_blocks_to_json(const std::string &bin); + static std::string binary_blocks_fast_to_json(const std::string &bin); static void sort_txs_wallet(std::vector>& txs, const std::vector& hashes); static std::vector> get_and_sort_txs(const monero_wallet& wallet, const std::vector& tx_hashes); diff --git a/src/cpp/utils/py_monero_utils_bindings.cpp b/src/cpp/utils/py_monero_utils_bindings.cpp index 1bef2ca..c7019c0 100644 --- a/src/cpp/utils/py_monero_utils_bindings.cpp +++ b/src/cpp/utils/py_monero_utils_bindings.cpp @@ -173,6 +173,10 @@ void py_monero_bind_utils(py::module_& m, PyMoneroTypes& t) { std::string b{bin}; MONERO_CATCH_AND_RETHROW(PyMoneroUtils::binary_blocks_to_json(b)); }, py::arg("bin")) + .def_static("binary_blocks_fast_to_json", [](const py::bytes &bin) { + std::string b{bin}; + MONERO_CATCH_AND_RETHROW(PyMoneroUtils::binary_blocks_fast_to_json(b)); + }, py::arg("bin")) .def_static("log_debug", [](const std::string &message) { MDEBUG(message); }, py::arg("message")) diff --git a/src/cpp/wallet/py_monero_wallet.h b/src/cpp/wallet/py_monero_wallet.h index 01f215e..b76ad1d 100644 --- a/src/cpp/wallet/py_monero_wallet.h +++ b/src/cpp/wallet/py_monero_wallet.h @@ -111,11 +111,11 @@ class PyMoneroWallet : public monero_wallet { } void set_daemon_connection(const std::string& uri, const std::string& username = "", const std::string& password = "", const std::string& proxy = "", const boost::optional& is_trusted = boost::none) override { - PYBIND11_OVERRIDE(void, monero_wallet, set_daemon_connection, uri, username, password, proxy); + PYBIND11_OVERRIDE(void, monero_wallet, set_daemon_connection, uri, username, password, proxy, is_trusted); } void set_daemon_connection(const std::shared_ptr& connection, const boost::optional& is_trusted = boost::none) override { - PYBIND11_OVERRIDE(void, monero_wallet, set_daemon_connection, connection); + PYBIND11_OVERRIDE(void, monero_wallet, set_daemon_connection, connection, is_trusted); } std::shared_ptr get_daemon_connection() const override { diff --git a/src/cpp/wallet/py_monero_wallet_bindings.cpp b/src/cpp/wallet/py_monero_wallet_bindings.cpp index c0bc481..d837901 100644 --- a/src/cpp/wallet/py_monero_wallet_bindings.cpp +++ b/src/cpp/wallet/py_monero_wallet_bindings.cpp @@ -65,6 +65,7 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { .def_readwrite("password", &monero_wallet_config::m_password) .def_readwrite("network_type", &monero_wallet_config::m_network_type) .def_readwrite("server", &monero_wallet_config::m_server) + .def_readwrite("is_trusted_daemon", &monero_wallet_config::m_is_trusted_daemon) .def_readwrite("seed", &monero_wallet_config::m_seed) .def_readwrite("seed_offset", &monero_wallet_config::m_seed_offset) .def_readwrite("primary_address", &monero_wallet_config::m_primary_address) @@ -309,7 +310,6 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { .def_readwrite("incoming_transfers", &monero_tx_wallet::m_incoming_transfers) .def_readwrite("outgoing_transfer", &monero_tx_wallet::m_outgoing_transfer) .def_readwrite("note", &monero_tx_wallet::m_note) - .def_readwrite("is_locked", &monero_tx_wallet::m_is_locked) .def_readwrite("input_sum", &monero_tx_wallet::m_input_sum) .def_readwrite("output_sum", &monero_tx_wallet::m_output_sum) .def_readwrite("change_address", &monero_tx_wallet::m_change_address) @@ -317,18 +317,10 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { .def_readwrite("num_dummy_outputs", &monero_tx_wallet::m_num_dummy_outputs) .def_readwrite("extra_hex", &monero_tx_wallet::m_extra_hex) .def("get_incoming_amount", [](monero_tx_wallet& self) { - uint64_t amount = 0; - for (const auto& transfer : self.m_incoming_transfers) { - if (transfer->m_amount != boost::none) - amount += transfer->m_amount.get(); - } - return amount; + MONERO_CATCH_AND_RETHROW(self.get_incoming_amount()); }) .def("get_outgoing_amount", [](monero_tx_wallet& self) { - uint64_t amount = 0; - if (self.m_outgoing_transfer != nullptr && self.m_outgoing_transfer->m_amount != boost::none) - amount = self.m_outgoing_transfer->m_amount.get(); - return amount; + MONERO_CATCH_AND_RETHROW(self.get_outgoing_amount()); }) .def("get_transfers", [](monero_tx_wallet& self) { MONERO_CATCH_AND_RETHROW(self.get_transfers()); @@ -339,16 +331,15 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { .def("filter_transfers", [](monero_tx_wallet& self, const monero_transfer_query& query) { MONERO_CATCH_AND_RETHROW(self.filter_transfers(query)); }, py::arg("query")) - .def("get_inputs_wallet", [](monero_tx_wallet& self, const boost::optional& query) { - std::vector> inputs; - for(const auto& i : self.m_inputs) { - auto input = std::dynamic_pointer_cast(i); - if (!input) continue; - if (query == boost::none || query.value().meets_criteria(input.get())) - inputs.push_back(input); - } - return inputs; - }, py::arg("query") = py::none()) + .def("get_inputs_wallet", [](monero_tx_wallet& self) { + MONERO_CATCH_AND_RETHROW(self.get_inputs_wallet()); + }) + .def("get_inputs_wallet", [](monero_tx_wallet& self, const monero_output_query& query) { + MONERO_CATCH_AND_RETHROW(self.get_inputs_wallet(query)); + }, py::arg("query")) + .def("filter_inputs_wallet", [](monero_tx_wallet& self, const monero_output_query& query) { + MONERO_CATCH_AND_RETHROW(self.filter_inputs_wallet(query)); + }, py::arg("query")) .def("get_outputs_wallet", [](monero_tx_wallet& self) { MONERO_CATCH_AND_RETHROW(self.get_outputs_wallet()); }) @@ -595,10 +586,10 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { MONERO_CATCH_AND_RETHROW(self.is_view_only()); }, py::call_guard()) .def("set_daemon_connection", [](PyMoneroWallet& self, const std::shared_ptr& connection, const boost::optional& is_trusted) { - MONERO_CATCH_AND_RETHROW(self.set_daemon_connection(connection)); + MONERO_CATCH_AND_RETHROW(self.set_daemon_connection(connection, is_trusted)); }, py::arg("connection"), py::arg("is_trusted") = py::none(), py::call_guard()) .def("set_daemon_connection", [](PyMoneroWallet& self, const std::string& uri, const std::string& username, const std::string& password, const std::string& proxy, const boost::optional& is_trusted) { - MONERO_CATCH_AND_RETHROW(self.set_daemon_connection(uri, username, password, proxy)); + MONERO_CATCH_AND_RETHROW(self.set_daemon_connection(uri, username, password, proxy, is_trusted)); }, py::arg("uri"), py::arg("username") = "", py::arg("password") = "", py::arg("proxy") = "", py::arg("is_trusted") = py::none(), py::call_guard()) .def("get_daemon_connection", [](PyMoneroWallet& self) { MONERO_CATCH_AND_RETHROW(self.get_daemon_connection()); @@ -606,6 +597,9 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { .def("is_connected_to_daemon", [](PyMoneroWallet& self) { MONERO_CATCH_AND_RETHROW(self.is_connected_to_daemon()); }, py::call_guard()) + .def("is_daemon_synced", [](PyMoneroWallet& self) { + MONERO_CATCH_AND_RETHROW(self.is_daemon_synced()); + }, py::call_guard()) .def("is_daemon_trusted", [](PyMoneroWallet& self) { MONERO_CATCH_AND_RETHROW(self.is_daemon_trusted()); }, py::call_guard()) @@ -1084,12 +1078,12 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { .def_static("get_seed_languages", []() { MONERO_CATCH_AND_RETHROW(monero_wallet_full::get_seed_languages()); }, py::call_guard()) - .def("get_keys_file_buffer", [](monero_wallet_full& self, std::string& password, bool view_only) { - MONERO_CATCH_AND_RETHROW(self.get_keys_file_buffer(password, view_only)); - }, py::arg("password"), py::arg("view_only"), py::call_guard()) - .def("get_cache_file_buffer", [](monero_wallet_full& self) { - MONERO_CATCH_AND_RETHROW(self.get_cache_file_buffer()); - }, py::call_guard()); + .def("get_keys_file_buffer", [](monero_wallet_full& self, std::string& password, bool view_only) -> py::bytes { + MONERO_CATCH_AND_RETHROW(py::bytes(self.get_keys_file_buffer(password, view_only))); + }, py::arg("password"), py::arg("view_only")) + .def("get_cache_file_buffer", [](monero_wallet_full& self) -> py::bytes { + MONERO_CATCH_AND_RETHROW(py::bytes(self.get_cache_file_buffer())); + }); // monero_wallet_rpc t.py_monero_wallet_rpc @@ -1111,12 +1105,12 @@ void py_monero_bind_wallet(py::module_& m, PyMoneroTypes& t) { MONERO_CATCH_AND_RETHROW(self.get_rpc_connection()); }, py::call_guard()) // this because of function hiding - .def("set_daemon_connection", [](PyMoneroWallet& self, const std::shared_ptr& connection) { - MONERO_CATCH_AND_RETHROW(self.set_daemon_connection(connection)); - }, py::arg("connection"), py::call_guard()) - .def("set_daemon_connection", [](PyMoneroWallet& self, const std::string& uri, const std::string& username, const std::string& password, const std::string& proxy) { - MONERO_CATCH_AND_RETHROW(self.set_daemon_connection(uri, username, password, proxy)); - }, py::arg("uri"), py::arg("username") = "", py::arg("password") = "", py::arg("proxy") = "", py::call_guard()) + .def("set_daemon_connection", [](PyMoneroWallet& self, const std::shared_ptr& connection, const boost::optional& is_trusted) { + MONERO_CATCH_AND_RETHROW(self.set_daemon_connection(connection, is_trusted)); + }, py::arg("connection"), py::arg("is_trusted") = py::none(), py::call_guard()) + .def("set_daemon_connection", [](PyMoneroWallet& self, const std::string& uri, const std::string& username, const std::string& password, const std::string& proxy, const boost::optional& is_trusted) { + MONERO_CATCH_AND_RETHROW(self.set_daemon_connection(uri, username, password, proxy, is_trusted)); + }, py::arg("uri"), py::arg("username") = "", py::arg("password") = "", py::arg("proxy") = "", py::arg("is_trusted") = py::none(), py::call_guard()) .def("set_daemon_connection", [](monero_wallet_rpc& self, const std::shared_ptr& connection, bool is_trusted, const boost::optional& ssl_options) { MONERO_CATCH_AND_RETHROW(self.set_daemon_connection(connection, is_trusted, ssl_options)); }, py::arg("connection"), py::arg("is_trusted"), py::arg("ssl_options"), py::call_guard()) diff --git a/src/python/__init__.pyi b/src/python/__init__.pyi index cbd172e..a953b0e 100644 --- a/src/python/__init__.pyi +++ b/src/python/__init__.pyi @@ -60,7 +60,9 @@ from .monero_account import MoneroAccount from .monero_account_tag import MoneroAccountTag from .monero_address_book_entry import MoneroAddressBookEntry from .monero_address_type import MoneroAddressType +from .monero_add_auxiliary_pow_result import MoneroAddAuxiliaryPowResult from .monero_alt_chain import MoneroAltChain +from .monero_auxiliary_pow import MoneroAuxiliaryPow from .monero_ban import MoneroBan from .monero_block import MoneroBlock from .monero_block_header import MoneroBlockHeader @@ -73,6 +75,7 @@ from .monero_connection_type import MoneroConnectionType from .monero_daemon import MoneroDaemon from .monero_daemon_info import MoneroDaemonInfo from .monero_daemon_listener import MoneroDaemonListener +from .monero_daemon_network_stats import MoneroDaemonNetworkStats from .monero_daemon_rpc import MoneroDaemonRpc from .monero_daemon_sync_info import MoneroDaemonSyncInfo from .monero_daemon_update_check_result import MoneroDaemonUpdateCheckResult @@ -83,6 +86,8 @@ from .monero_error import MoneroError from .gen_utils import GenUtils from .monero_fee_estimate import MoneroFeeEstimate from .monero_generate_blocks_result import MoneroGenerateBlocksResult +from .monero_get_block_hashes_result import MoneroGetBlockHashesResult +from .monero_get_blocks_by_hash_result import MoneroGetBlocksByHashResult from .monero_hard_fork_info import MoneroHardForkInfo from .incoming_transfer_comparator import IncomingTransferComparator from .monero_incoming_transfer import MoneroIncomingTransfer @@ -93,6 +98,7 @@ from .monero_key_image_import_result import MoneroKeyImageImportResult from .monero_key_image_spent_status import MoneroKeyImageSpentStatus from .monero_message_signature_result import MoneroMessageSignatureResult from .monero_message_signature_type import MoneroMessageSignatureType +from .monero_miner_data import MoneroMinerData from .monero_miner_tx_sum import MoneroMinerTxSum from .monero_mining_status import MoneroMiningStatus from .monero_multisig_info import MoneroMultisigInfo @@ -142,7 +148,9 @@ __all__ = [ 'MoneroAccountTag', 'MoneroAddressBookEntry', 'MoneroAddressType', + 'MoneroAddAuxiliaryPowResult', 'MoneroAltChain', + 'MoneroAuxiliaryPow', 'MoneroBan', 'MoneroBlock', 'MoneroBlockHeader', @@ -155,6 +163,7 @@ __all__ = [ 'MoneroDaemon', 'MoneroDaemonInfo', 'MoneroDaemonListener', + 'MoneroDaemonNetworkStats', 'MoneroDaemonRpc', 'MoneroDaemonSyncInfo', 'MoneroDaemonUpdateCheckResult', @@ -165,6 +174,8 @@ __all__ = [ 'GenUtils', 'MoneroFeeEstimate', 'MoneroGenerateBlocksResult', + 'MoneroGetBlockHashesResult', + 'MoneroGetBlocksByHashResult', 'MoneroHardForkInfo', 'IncomingTransferComparator', 'MoneroIncomingTransfer', @@ -175,6 +186,7 @@ __all__ = [ 'MoneroKeyImageSpentStatus', 'MoneroMessageSignatureResult', 'MoneroMessageSignatureType', + 'MoneroMinerData', 'MoneroMinerTxSum', 'MoneroMiningStatus', 'MoneroMultisigInfo', diff --git a/src/python/monero_add_auxiliary_pow_result.pyi b/src/python/monero_add_auxiliary_pow_result.pyi new file mode 100644 index 0000000..25f538c --- /dev/null +++ b/src/python/monero_add_auxiliary_pow_result.pyi @@ -0,0 +1,31 @@ +from .monero_rpc_payment_info import MoneroRpcPaymentInfo +from .monero_auxiliary_pow import MoneroAuxiliaryPow + + +class MoneroAddAuxiliaryPowResult(MoneroRpcPaymentInfo): + """Models the result of adding auxiliary proof-of-work to a block template for merge mining.""" + + block_template_blob: str | None + """The updated block template blob.""" + block_hashing_blob: str | None + """The updated block hashing blob.""" + merkle_root: str | None + """The Merkle root committing to the auxiliary blocks.""" + merkle_tree_depth: int | None + """The depth of the auxiliary Merkle tree.""" + aux_pow: list[MoneroAuxiliaryPow] + """The (possibly reordered) auxiliary proof-of-work entries.""" + + @staticmethod + def deserialize(json: str) -> MoneroAddAuxiliaryPowResult: + """ + Deserialize a MoneroAddAuxiliaryPowResult from a JSON string. + + :param str json: MoneroAddAuxiliaryPowResult in JSON format. + :returns MoneroAddAuxiliaryPowResult: deserialized instance. + """ + ... + + def __init__(self) -> None: + """Initialize a Monero add auxiliary proof-of-work result.""" + ... diff --git a/src/python/monero_auxiliary_pow.pyi b/src/python/monero_auxiliary_pow.pyi new file mode 100644 index 0000000..d120baa --- /dev/null +++ b/src/python/monero_auxiliary_pow.pyi @@ -0,0 +1,37 @@ +import typing + +from .serializable_struct import SerializableStruct + + +class MoneroAuxiliaryPow(SerializableStruct): + """Identifies an auxiliary chain's block by id and proof-of-work hash for merge mining.""" + + id: str | None + """The auxiliary chain id.""" + hash: str | None + """The auxiliary block's proof-of-work hash.""" + + @staticmethod + def deserialize(json: str) -> MoneroAuxiliaryPow: + """ + Deserialize a MoneroAuxiliaryPow from a JSON string. + + :param str json: MoneroAuxiliaryPow in JSON format. + :returns MoneroAuxiliaryPow: deserialized instance. + """ + ... + + @typing.overload + def __init__(self) -> None: + """Initialize an empty Monero auxiliary proof-of-work.""" + ... + + @typing.overload + def __init__(self, id: str, hash: str) -> None: + """ + Initialize a Monero auxiliary proof-of-work. + + :param str id: the auxiliary chain id. + :param str hash: the auxiliary block's proof-of-work hash. + """ + ... diff --git a/src/python/monero_block_header.pyi b/src/python/monero_block_header.pyi index 13a201e..6de91fb 100644 --- a/src/python/monero_block_header.pyi +++ b/src/python/monero_block_header.pyi @@ -39,7 +39,10 @@ class MoneroBlockHeader(SerializableStruct): prev_hash: str | None """The hash of the block immediately preceding this block in the chain.""" reward: int | None - """The amount of atomic-units rewarded to the miner. The reward is the sum of new coins created (the emission) and fees paid by transactions in this block. Note: 1 XMR = 1e12 atomic-units.""" + """ + The amount of atomic-units rewarded to the miner. The reward is the sum of new coins created + (the emission) and fees paid by transactions in this block. Note: 1 XMR = 1e12 atomic-units. + """ size: int | None """Backward compatibility, same as `weight`, use that instead.""" timestamp: int | None diff --git a/src/python/monero_block_template.pyi b/src/python/monero_block_template.pyi index cba5f3d..ef361b1 100644 --- a/src/python/monero_block_template.pyi +++ b/src/python/monero_block_template.pyi @@ -21,7 +21,11 @@ class MoneroBlockTemplate(SerializableStruct): prev_hash: str | None """Hash of the most recent block on which to mine the next block.""" reserved_offset: int | None - """Reserved offset.""" + """ + Byte offset into `block_template_blob` of the `reserve_size` scratch bytes the daemon + left free in the coinbase transaction's extra field, where a pool writes its own data + (e.g. an extra-nonce it varies to search the nonce space). + """ seed_hash: str | None """Hash of block to use as seed for Random-X proof-of-work.""" seed_height: int | None diff --git a/src/python/monero_connection_span.pyi b/src/python/monero_connection_span.pyi index e34b59a..7ffa9dd 100644 --- a/src/python/monero_connection_span.pyi +++ b/src/python/monero_connection_span.pyi @@ -5,17 +5,17 @@ class MoneroConnectionSpan(SerializableStruct): """Monero daemon connection span.""" connection_id: str | None - """Id of connection""" + """Id of the P2P connection this span of blocks was (or is being) downloaded over.""" num_blocks: int | None - """Number of blocks in this span""" + """Number of blocks in this span.""" rate: int | None - """Connection rate""" + """Download rate for this span, in bytes per second.""" remote_address: str | None """Peer address the node is downloading (or has downloaded) than span from.""" size: int | None """Total number of bytes in that span's blocks (including txes).""" speed: int | None - """Connection speed.""" + """Relative speed of this connection as a percentage (0-100) of the fastest peer currently downloading blocks.""" start_height: int | None """Block height of the first block in that span.""" diff --git a/src/python/monero_daemon.pyi b/src/python/monero_daemon.pyi index 791efa5..f1db8f0 100644 --- a/src/python/monero_daemon.pyi +++ b/src/python/monero_daemon.pyi @@ -26,6 +26,12 @@ from .monero_tx_pool_stats import MoneroTxPoolStats from .monero_version import MoneroVersion from .monero_prune_result import MoneroPruneResult from .monero_submit_tx_result import MoneroSubmitTxResult +from .monero_miner_data import MoneroMinerData +from .monero_auxiliary_pow import MoneroAuxiliaryPow +from .monero_add_auxiliary_pow_result import MoneroAddAuxiliaryPowResult +from .monero_daemon_network_stats import MoneroDaemonNetworkStats +from .monero_get_blocks_by_hash_result import MoneroGetBlocksByHashResult +from .monero_get_block_hashes_result import MoneroGetBlockHashesResult class MoneroDaemon: @@ -43,6 +49,28 @@ class MoneroDaemon: """ ... + def add_auxiliary_pow(self, block_template_blob: str, aux_pow: list[MoneroAuxiliaryPow]) -> MoneroAddAuxiliaryPowResult: + """ + Add auxiliary proof-of-work to a block template for merge mining. + + :param str block_template_blob: the block template blob to add auxiliary PoW to. + :param list[MoneroAuxiliaryPow] aux_pow: identifies each auxiliary chain by id and its block's PoW hash. + :returns MoneroAddAuxiliaryPowResult: the updated block template along with the (possibly reordered) auxiliary PoW. + """ + ... + + def calculate_pow(self, major_version: int, height: int, block_blob: str, seed_hash: str) -> str: + """ + Calculate the proof-of-work hash of a mined block. + + :param int major_version: the block's major version. + :param int height: the block's height. + :param str block_blob: the block's blob to hash. + :param str seed_hash: the seed hash used to select the RandomX dataset/cache. + :returns str: the block's proof-of-work hash. + """ + ... + def check_for_update(self) -> MoneroDaemonUpdateCheckResult: """ Check for update. @@ -60,6 +88,14 @@ class MoneroDaemon: """ ... + def flush_cache(self, bad_blocks: bool = False) -> None: + """ + Flush the daemon's invalid block and transaction caches. + + :param bool bad_blocks: specifies to also flush the bad blocks cache. + """ + ... + @typing.overload def flush_tx_pool(self) -> None: """Flush transactions from the tx pool.""" @@ -126,15 +162,15 @@ class MoneroDaemon: """ ... - def get_block_hashes(self, block_hashes: list[str], start_height: int) -> list[str]: + def get_block_hashes(self, block_hashes: list[str]) -> MoneroGetBlockHashesResult: """ Get block hashes as a binary request to the daemon. - :param list[str] block_hashes: specify block hashes to fetch; first 10 blocks - hash goes sequential, next goes in pow(2,n) offset, - like 2, 4, 8, 16, 32, 64 and so on, and the last one is always genesis block. - :param int start_height: is the starting height of block hashes to return. - :returns list[str]: the requested block hashes. + :param list[str] block_hashes: a short chain history; first 10 block hashes go + sequential, next go in pow(2,n) offset, like 2, 4, 8, 16, 32, 64 and so on, and + the last one is always the genesis block, which is required or the request fails. + :returns MoneroGetBlockHashesResult: the requested hashes plus their start height + and the daemon's current chain height. """ ... @@ -176,14 +212,18 @@ class MoneroDaemon: """ ... - def get_blocks_by_hash(self, block_hashes: list[str], start_height: int, prune: bool) -> list[MoneroBlock]: + def get_blocks_by_hash(self, block_hashes: list[str], start_height: int, prune: bool, max_block_count: int = 0) -> MoneroGetBlocksByHashResult: """ - Get a block by hash. + Get blocks by hash. - :param list[str] block_hashes: is the hash of the block to get. - :param int start_height: filter blocks by block height. - :param bool prune: prune hash. - :returns list[MoneroBlock]: the block with the given hash. + :param list[str] block_hashes: a short chain history; first 10 block hashes go + sequential, next go in pow(2,n) offset, and the last one is always the genesis block. + :param int start_height: the start height to resume from; when non-zero the daemon + skips the search for the last common block and uses it as-is. + :param bool prune: whether returned blocks should be pruned. + :param int max_block_count: caps how many blocks the daemon returns in one call + (0 leaves it to the daemon's own default limit). + :returns MoneroGetBlocksByHashResult: the retrieved blocks plus the daemon's current chain height. """ ... @@ -206,7 +246,12 @@ class MoneroDaemon: """ ... - def get_blocks_by_range_chunked(self, start_height: typing.Optional[int], end_height: typing.Optional[int], max_chunk_size: typing.Optional[int] = None) -> list[MoneroBlock]: + def get_blocks_by_range_chunked( + self, + start_height: typing.Optional[int], + end_height: typing.Optional[int], + max_chunk_size: typing.Optional[int] = None, + ) -> list[MoneroBlock]: """ Get blocks in the given height range as chunked requests so that each request is not too big. @@ -301,6 +346,15 @@ class MoneroDaemon: """ ... + def get_miner_data(self) -> MoneroMinerData: + """ + Get the data needed to construct a block template for mining, e.g. for use by a + pool that assembles its own block templates. + + :returns MoneroMinerData: the current data for mining a new block. + """ + ... + def get_miner_tx_sum(self, height: int, num_blocks: int) -> MoneroMinerTxSum: """ Gets the total emissions and fees from the genesis block to the current height. @@ -319,7 +373,13 @@ class MoneroDaemon: """ ... - def generate_blocks(self, wallet_address: str, num_blocks: int, prev_block_hash: str | None = None, starting_nonce: int | None = None) -> MoneroGenerateBlocksResult: + def generate_blocks( + self, + wallet_address: str, + num_blocks: int, + prev_block_hash: str | None = None, + starting_nonce: int | None = None, + ) -> MoneroGenerateBlocksResult: """ Generate blocks to a wallet address (regtest only). @@ -330,7 +390,13 @@ class MoneroDaemon: :returns MoneroGenerateBlockResult: the result of generating blocks; height is the height of the last block generated. """ - def get_output_distribution(self, amounts: list[int], is_cumulative: bool | None = None, start_height: int | None = None, end_height: int | None = None) -> list[MoneroOutputDistributionEntry]: + def get_output_distribution( + self, + amounts: list[int], + is_cumulative: bool | None = None, + start_height: int | None = None, + end_height: int | None = None, + ) -> list[MoneroOutputDistributionEntry]: """ Creates an output distribution. @@ -342,7 +408,14 @@ class MoneroDaemon: """ ... - def get_output_histogram(self, amounts: list[int], min_count: int | None, max_count: int | None, is_unlocked: bool | None, recent_cutoff: int | None) -> list[MoneroOutputHistogramEntry]: + def get_output_histogram( + self, + amounts: list[int], + min_count: int | None, + max_count: int | None, + is_unlocked: bool | None, + recent_cutoff: int | None, + ) -> list[MoneroOutputHistogramEntry]: """ Get a histogram of output amounts. For all amounts (possibly filtered by parameters), gives the number of outputs on the chain for that amount. @@ -357,6 +430,23 @@ class MoneroDaemon: """ ... + def get_network_stats(self) -> MoneroDaemonNetworkStats: + """ + Get network (bandwidth) statistics since the daemon started. + + :returns MoneroDaemonNetworkStats: the daemon's network statistics. + """ + ... + + def get_output_indices(self, tx_hash: str) -> list[int]: + """ + Get the global output index of each output in a transaction. + + :param str tx_hash: the hash of the transaction to get output indices for. + :returns list[int]: the global output index of each output in the transaction, in order. + """ + ... + def get_outputs(self, outputs: list[MoneroOutput]) -> list[MoneroOutput]: """ Get outputs identified by a list of output amounts and indices as a binary @@ -367,6 +457,15 @@ class MoneroDaemon: """ ... + def get_peer_ban(self, address: str) -> MoneroBan: + """ + Get the ban status of a peer node. + + :param str address: the address of the peer node to check, e.g. "1.2.3.4" or "1.2.3.4:18080". + :returns MoneroBan: the peer's ban status. + """ + ... + def get_peer_bans(self) -> list[MoneroBan]: """ Get peer bans. @@ -383,6 +482,15 @@ class MoneroDaemon: """ ... + def get_public_peers(self, include_offline: bool = False) -> list[MoneroPeer]: + """ + Get public nodes known to the daemon. + + :param bool include_offline: specifies if offline nodes should be included. + :returns list[MoneroPeer]: the daemon's known public nodes. + """ + ... + def get_sync_info(self) -> MoneroDaemonSyncInfo: """ Get synchronization information. @@ -488,6 +596,15 @@ class MoneroDaemon: """ ... + def pop_blocks(self, num_blocks: int) -> int: + """ + Pop (remove) blocks from the top of the blockchain. + + :param int num_blocks: the number of blocks to pop. + :returns int: the blockchain height after popping the blocks. + """ + ... + def prune_blockchain(self, check: bool) -> MoneroPruneResult: """ Prune the blockchain. @@ -513,6 +630,10 @@ class MoneroDaemon: """ ... + def remove_bootstrap_daemon(self) -> None: + """Disable the bootstrap daemon so the daemon no longer falls back to it.""" + ... + def remove_listener(self, listener: MoneroDaemonListener) -> None: """ Unregister a listener to receive daemon notifications. @@ -521,6 +642,10 @@ class MoneroDaemon: """ ... + def remove_listeners(self) -> None: + """Unregister all listeners registered with the daemon.""" + ... + def reset_download_limit(self) -> int: """ Reset the download bandwidth limit. @@ -537,6 +662,23 @@ class MoneroDaemon: """ ... + def save_blockchain(self) -> None: + """Save (flush) the blockchain to disk.""" + ... + + def set_bootstrap_daemon(self, address: str, username: str = '', password: str = '', proxy: str = '') -> None: + """ + Set the bootstrap daemon used by the daemon to serve requests while it is not + fully synced, e.g. a remote node. + + :param str address: the bootstrap daemon's address (host:port), "auto" to select a + public node automatically, or an empty string to disable the bootstrap daemon. + :param str username: the username to authenticate with the bootstrap daemon (optional). + :param str password: the password to authenticate with the bootstrap daemon (optional). + :param str proxy: the proxy used to reach the bootstrap daemon, e.g. a SOCKS proxy (optional). + """ + ... + def set_download_limit(self, limit: int) -> int: """ Set the download bandwidth limit. @@ -578,6 +720,32 @@ class MoneroDaemon: """ ... + def set_log_categories(self, categories: str = '') -> str: + """ + Set the daemon's log categories. + + :param str categories: the log categories to set, e.g. "*:WARNING,net.p2p:DEBUG" + (an empty string resets categories to the default). + :returns str: the daemon's resulting log categories. + """ + ... + + def set_log_hash_rate(self, is_visible: bool) -> None: + """ + Show or hide the mining hash rate in the daemon's console log. + + :param bool is_visible: specifies if the hash rate should be logged. + """ + ... + + def set_log_level(self, level: int) -> None: + """ + Set the daemon's log level. + + :param int level: the log level to set, from 0 (least verbose) to 4 (most verbose). + """ + ... + def set_upload_limit(self, limit: int) -> int: """ Set the upload bandwidth limit. diff --git a/src/python/monero_daemon_info.pyi b/src/python/monero_daemon_info.pyi index 7bf47c9..d5f9a0b 100644 --- a/src/python/monero_daemon_info.pyi +++ b/src/python/monero_daemon_info.pyi @@ -37,6 +37,8 @@ class MoneroDaemonInfo(MoneroRpcPaymentInfo): """States if new blocks are being added (`True`) or not (`False`).""" is_offline: bool | None """States if the node is offline (`True`) or online (`False`).""" + is_regtest: bool | None + """States if the node is running in regtest mode (`True`) or not (`False`).""" is_restricted: bool | None """Indicates that the node RPC interface is restricted (`True`) or not (`False`).""" is_synchronized: bool | None diff --git a/src/python/monero_daemon_network_stats.pyi b/src/python/monero_daemon_network_stats.pyi new file mode 100644 index 0000000..efc3e39 --- /dev/null +++ b/src/python/monero_daemon_network_stats.pyi @@ -0,0 +1,30 @@ +from .monero_rpc_payment_info import MoneroRpcPaymentInfo + + +class MoneroDaemonNetworkStats(MoneroRpcPaymentInfo): + """Models daemon network (bandwidth) statistics since the daemon started.""" + + start_time: int | None + """Unix timestamp when the statistics window started.""" + total_packets_in: int | None + """Total number of packets received.""" + total_bytes_in: int | None + """Total number of bytes received.""" + total_packets_out: int | None + """Total number of packets sent.""" + total_bytes_out: int | None + """Total number of bytes sent.""" + + @staticmethod + def deserialize(json: str) -> MoneroDaemonNetworkStats: + """ + Deserialize a MoneroDaemonNetworkStats from a JSON string. + + :param str json: MoneroDaemonNetworkStats in JSON format. + :returns MoneroDaemonNetworkStats: deserialized instance. + """ + ... + + def __init__(self) -> None: + """Initialize a Monero daemon network stats.""" + ... diff --git a/src/python/monero_get_block_hashes_result.pyi b/src/python/monero_get_block_hashes_result.pyi new file mode 100644 index 0000000..ae9dac6 --- /dev/null +++ b/src/python/monero_get_block_hashes_result.pyi @@ -0,0 +1,26 @@ +from .serializable_struct import SerializableStruct + + +class MoneroGetBlockHashesResult(SerializableStruct): + """Models the result of getting block hashes.""" + + hashes: list[str] + """The requested block hashes, starting at (and including) the last block in common with the request.""" + start_height: int | None + """The height of the first hash in `hashes`.""" + current_height: int | None + """The daemon's chain height at request time.""" + + @staticmethod + def deserialize(json: str) -> MoneroGetBlockHashesResult: + """ + Deserialize a MoneroGetBlockHashesResult from a JSON string. + + :param str json: MoneroGetBlockHashesResult in JSON format. + :returns MoneroGetBlockHashesResult: deserialized instance. + """ + ... + + def __init__(self) -> None: + """Initialize a Monero get block hashes result.""" + ... diff --git a/src/python/monero_get_blocks_by_hash_result.pyi b/src/python/monero_get_blocks_by_hash_result.pyi new file mode 100644 index 0000000..03cfefa --- /dev/null +++ b/src/python/monero_get_blocks_by_hash_result.pyi @@ -0,0 +1,25 @@ +from .serializable_struct import SerializableStruct +from .monero_block import MoneroBlock + + +class MoneroGetBlocksByHashResult(SerializableStruct): + """Models the result of getting blocks by hash.""" + + blocks: list[MoneroBlock] + """The retrieved blocks.""" + current_height: int | None + """The daemon's chain height at request time.""" + + @staticmethod + def deserialize(json: str) -> MoneroGetBlocksByHashResult: + """ + Deserialize a MoneroGetBlocksByHashResult from a JSON string. + + :param str json: MoneroGetBlocksByHashResult in JSON format. + :returns MoneroGetBlocksByHashResult: deserialized instance. + """ + ... + + def __init__(self) -> None: + """Initialize a Monero get blocks by hash result.""" + ... diff --git a/src/python/monero_key_image_export_result.pyi b/src/python/monero_key_image_export_result.pyi index 30b515f..d88825b 100644 --- a/src/python/monero_key_image_export_result.pyi +++ b/src/python/monero_key_image_export_result.pyi @@ -6,9 +6,16 @@ class MoneroKeyImageExportResult(SerializableStruct): """Models results from exporting key images.""" offset: int | None - """Offset height.""" + """ + Index of the first exported key image within the wallet's list of owned outputs. + + On an incremental export (`all=False`) the wallet skips outputs whose key + images were already exported, so this offset tells the importing wallet where + in its own output list the returned key images begin. Pass it back as the + `offset` argument of `MoneroWallet.import_key_images()`. + """ key_images: list[MoneroKeyImage] - """Exported key images.""" + """The exported key images, one per owned output starting at `offset`.""" def __init__(self) -> None: """Initialize a Monero key image export result.""" diff --git a/src/python/monero_miner_data.pyi b/src/python/monero_miner_data.pyi new file mode 100644 index 0000000..4690fd8 --- /dev/null +++ b/src/python/monero_miner_data.pyi @@ -0,0 +1,37 @@ +from .monero_rpc_payment_info import MoneroRpcPaymentInfo +from .monero_tx import MoneroTx + + +class MoneroMinerData(MoneroRpcPaymentInfo): + """Data needed to construct a block template for mining, e.g. for a pool that assembles its own block templates.""" + + major_version: int | None + """The next block's major version.""" + height: int | None + """The height of the next block to mine.""" + prev_hash: str | None + """The hash of the previous (current top) block.""" + seed_hash: str | None + """The seed hash used to select the RandomX dataset/cache.""" + difficulty: str | None + """The next block's difficulty as a hex string, e.g. "0x1f4".""" + median_weight: int | None + """The median block weight used for penalty calculations.""" + already_generated_coins: int | None + """The total coins emitted so far, in atomic-units.""" + tx_pool_backlog: list[MoneroTx] + """The transactions currently in the pool eligible for the next block.""" + + @staticmethod + def deserialize(json: str) -> MoneroMinerData: + """ + Deserialize a MoneroMinerData from a JSON string. + + :param str json: MoneroMinerData in JSON format. + :returns MoneroMinerData: deserialized instance. + """ + ... + + def __init__(self) -> None: + """Initialize a Monero miner data.""" + ... diff --git a/src/python/monero_output.pyi b/src/python/monero_output.pyi index 3bef591..f959841 100644 --- a/src/python/monero_output.pyi +++ b/src/python/monero_output.pyi @@ -12,6 +12,8 @@ class MoneroOutput(SerializableStruct): """Output index.""" key_image: MoneroKeyImage | None """The key image of the output.""" + mask: str | None + """The output commitment mask (pseudo-out blinding factor) as a hex string.""" ring_output_indices: list[int] """Indices of ring outputs.""" stealth_public_key: str | None diff --git a/src/python/monero_output_distribution_entry.pyi b/src/python/monero_output_distribution_entry.pyi index 0af40db..2329976 100644 --- a/src/python/monero_output_distribution_entry.pyi +++ b/src/python/monero_output_distribution_entry.pyi @@ -9,9 +9,16 @@ class MoneroOutputDistributionEntry(SerializableStruct): base: int | None """The total number of outputs of `amount` in the chain before, not including, the block at `start_height`.""" distribution: list[int] - """The output distibution.""" + """ + Per-block counts of outputs of `amount` starting at `start_height`: element `i` is the number + created in block `start_height + i`, or the running total up to that block when the distribution + was requested as cumulative. Wallets use this to weight decoy selection by output age. + """ start_height: int | None - """Not necessarily equal to `start_height` parameter especially for `amount = 0` where `start_height` will be no less than the height of the v4 hardfork.""" + """ + Not necessarily equal to the `start_height` parameter, especially for `amount = 0` where it will + be no less than the height of the v4 hard fork. + """ @staticmethod def deserialize(json: str) -> MoneroOutputDistributionEntry: diff --git a/src/python/monero_rpc_connection.pyi b/src/python/monero_rpc_connection.pyi index 93d919c..d4d27a6 100644 --- a/src/python/monero_rpc_connection.pyi +++ b/src/python/monero_rpc_connection.pyi @@ -55,7 +55,16 @@ class MoneroRpcConnection(SerializableStruct): ... @typing.overload - def __init__(self, uri: str = '', username: str = '', password: str = '', proxy_uri: str = '', zmq_uri: str = '', priority: int = 0, timeout_ms: int | None = None) -> None: + def __init__( + self, + uri: str = '', + username: str = '', + password: str = '', + proxy_uri: str = '', + zmq_uri: str = '', + priority: int = 0, + timeout_ms: int | None = None, + ) -> None: """ Initialize a RPC connection. @@ -102,7 +111,8 @@ class MoneroRpcConnection(SerializableStruct): Note: must call `check_connection()` manually. - :returns bool | None: `True` if authenticated or no authentication required, `False` if not authenticated, or `None` if `check_connection()` has not been called. + :returns bool | None: `True` if authenticated or no authentication required, `False` if not + authenticated, or `None` if `check_connection()` has not been called. """ ... diff --git a/src/python/monero_submit_tx_result.pyi b/src/python/monero_submit_tx_result.pyi index 9bfbf3e..0fbb479 100644 --- a/src/python/monero_submit_tx_result.pyi +++ b/src/python/monero_submit_tx_result.pyi @@ -18,6 +18,7 @@ class MoneroSubmitTxResult(MoneroRpcPaymentInfo): is_mixin_too_low: bool | None """Indicates if the transaction mixin count is too low.""" is_nonzero_unlock_time: bool | None + """Indicates if the transaction was rejected for carrying a non-zero per-transaction unlock time, which the daemon no longer accepts into the pool.""" is_overspend: bool | None """Indicates if the transaction uses more money than available""" is_relayed: bool | None diff --git a/src/python/monero_tx.pyi b/src/python/monero_tx.pyi index c11da01..577f601 100644 --- a/src/python/monero_tx.pyi +++ b/src/python/monero_tx.pyi @@ -32,6 +32,8 @@ class MoneroTx(SerializableStruct): """Indicates if the transaction validation has previously failed.""" is_kept_by_block: bool | None """States if the transaction was included in a block at least once (`True`) or not (`False`).""" + is_locked: bool | None + """Indicates if the transaction is locked.""" is_miner_tx: bool | None """States if the transaction is a coinbase-transaction (`True`) or not (`False`).""" is_relayed: bool | None @@ -53,7 +55,7 @@ class MoneroTx(SerializableStruct): num_confirmations: int | None """Number of network confirmations.""" output_indices: list[int] - """Transaction indexes.""" + """Global (on-chain) output index of each of this transaction's outputs, in order; the indices by which the outputs are referenced as ring members.""" outputs: list[MoneroOutput] """Transaction outputs.""" payment_id: str | None diff --git a/src/python/monero_tx_set.pyi b/src/python/monero_tx_set.pyi index 8a788bb..9d0f30e 100644 --- a/src/python/monero_tx_set.pyi +++ b/src/python/monero_tx_set.pyi @@ -12,13 +12,23 @@ class MoneroTxSet(SerializableStruct): transactions. """ multisig_tx_hex: str | None - "Multisignature transaction hex." + """ + Serialized multisig transaction data produced when a multisig wallet creates transactions; + passed to the other cosigners' `MoneroWallet.sign_multisig_tx_hex()` and finally to + `submit_multisig_tx_hex()` once enough signatures are collected. + """ signed_tx_hex: str | None - "Signed transaction hex." + """ + Serialized fully-signed transaction data returned by `MoneroWallet.sign_txs()` on the offline + spend wallet; pass it to `submit_txs()` on an online wallet to broadcast. + """ txs: list[MoneroTxWallet] - "List of transactions defined in this set." + """The structured transactions in this set (populated when the set is created locally or by `describe_tx_set()`).""" unsigned_tx_hex: str | None - "Unsigned transaction hex." + """ + Serialized unsigned transaction data produced by a view-only wallet's `create_tx()` / + `create_txs()`; transfer it to the offline spend wallet and call `MoneroWallet.sign_txs()`. + """ @staticmethod def deserialize(tx_set_json: str) -> MoneroTxSet: """ diff --git a/src/python/monero_tx_wallet.pyi b/src/python/monero_tx_wallet.pyi index 88e8416..a6bb893 100644 --- a/src/python/monero_tx_wallet.pyi +++ b/src/python/monero_tx_wallet.pyi @@ -25,8 +25,6 @@ class MoneroTxWallet(MoneroTx): """Total input sum.""" is_incoming: bool | None """Indicates if the transaction has incoming transfers.""" - is_locked: bool | None - """Indicates if the transaction is locked.""" is_outgoing: bool | None """Indicated if the transaction has outgoing transfer.""" note: str | None @@ -60,6 +58,14 @@ class MoneroTxWallet(MoneroTx): :returns MoneroTxWallet: tx wallet copy. """ ... + def filter_inputs_wallet(self, query: MoneroOutputQuery) -> list[MoneroOutputWallet]: + """ + Filter this tx's inputs in place, keeping only those that meet the query. + + :param MoneroOutputQuery query: query to filter inputs with. + :returns list[MoneroOutputWallet]: inputs that meet all criteria defined in `query`. + """ + ... def filter_outputs_wallet(self, query: MoneroOutputQuery) -> list[MoneroOutputWallet]: """ Get outputs filtered by query. @@ -93,12 +99,21 @@ class MoneroTxWallet(MoneroTx): :returns list[MoneroOutputWallet]: wallet outputs filtered by query. """ ... - def get_inputs_wallet(self, query: MoneroOutputQuery | None = None) -> list[MoneroOutputWallet]: + @typing.overload + def get_inputs_wallet(self) -> list[MoneroOutputWallet]: + """ + Get wallet inputs from current wallet tx. + + :returns list[MoneroOutputWallet]: wallet inputs defined in current tx. + """ + ... + @typing.overload + def get_inputs_wallet(self, query: MoneroOutputQuery) -> list[MoneroOutputWallet]: """ Get wallet inputs filtered by query. - :params MoneroOutputQuery query: query to filter outputs with. - :returns list[MoneroOutputWallet]: wallet outputs filtered by query. + :params MoneroOutputQuery query: query to filter inputs with. + :returns list[MoneroOutputWallet]: wallet inputs filtered by query. """ ... @typing.overload diff --git a/src/python/monero_utils.pyi b/src/python/monero_utils.pyi index 556530f..427df24 100644 --- a/src/python/monero_utils.pyi +++ b/src/python/monero_utils.pyi @@ -54,6 +54,16 @@ class MoneroUtils: """ ... + @staticmethod + def binary_blocks_fast_to_json(bin: bytes) -> str: + """ + Deserialize blocks JSON string from the daemon's fast (`get_blocks.bin`) binary format. + + :param bytes bin: blocks JSON string in the fast binary format. + :returns str: The deserialized blocks in JSON string format. + """ + ... + @staticmethod def configure_logging(path: str, console: bool) -> None: """ diff --git a/src/python/monero_wallet.pyi b/src/python/monero_wallet.pyi index 485ac5c..0e87889 100644 --- a/src/python/monero_wallet.pyi +++ b/src/python/monero_wallet.pyi @@ -207,7 +207,9 @@ class MoneroWallet: This process must be repeated with participants exactly N-M times. :param list[str] multisig_hexes: are multisig hex from each participant. - :param str password: is the wallet's password (TODO monero-project: redundant? wallet is created with password). + :param str password: the wallet's password, needed to decrypt the account keys in memory + for the key exchange (`wallet2` keeps them encrypted at rest unless the wallet is + unattended or view-only). :returns MoneroMultisigInitResult: the result which has the multisig's address xor this wallet's multisig hex to share with participants if not done. """ @@ -451,7 +453,7 @@ class MoneroWallet: :returns MoneroNetworkType: the wallet's network type. """ ... - def get_new_key_images_from_last_import(self) -> list[MoneroKeyImage]: + def get_new_key_images_from_last_import(self) -> MoneroKeyImageExportResult: """ Get new key images from the last imported outputs. @@ -839,6 +841,13 @@ class MoneroWallet: :returns bool: `True` if the wallet is connected to daemon, `False` otherwise. """ ... + def is_daemon_synced(self) -> bool: + """ + Indicates if the wallet's daemon is synced with the network. + + :returns bool: `True` if the daemon is synced, `False` otherwise. + """ + ... def is_daemon_trusted(self) -> bool: """ Indicates if the daemon is trusted or untrusted. @@ -889,7 +898,7 @@ class MoneroWallet: :param list[str] multisig_hexes: are multisig hex from each participant. :param int threshold: is the number of signatures needed to sign transfers. - :param str password: is the wallet password. + :param str password: the wallet's password, needed to decrypt the account keys in memory while converting the wallet to multisig. :returns str: this wallet's multisig hex to share with participants. """ ... diff --git a/src/python/monero_wallet_config.pyi b/src/python/monero_wallet_config.pyi index d1f5e64..546b643 100644 --- a/src/python/monero_wallet_config.pyi +++ b/src/python/monero_wallet_config.pyi @@ -36,6 +36,8 @@ class MoneroWalletConfig(SerializableStruct): """The wallet custom seed offset.""" server: MoneroRpcConnection | None """The wallet RPC connection.""" + is_trusted_daemon: bool | None + """Indicates whether the configured daemon connection should be treated as trusted.""" subaddress_lookahead: int | None """Subaddress index look ahead.""" regtest: bool | None diff --git a/src/python/monero_wallet_full.pyi b/src/python/monero_wallet_full.pyi index cd880dd..9c71f9d 100644 --- a/src/python/monero_wallet_full.pyi +++ b/src/python/monero_wallet_full.pyi @@ -40,14 +40,21 @@ class MoneroWalletFull(MoneroWallet): ... @staticmethod - def open_wallet_data(password: str, nettype: MoneroNetworkType, keys_data: str, cache_data: str, daemon_connection: MoneroRpcConnection = MoneroRpcConnection(), regtest: bool = False) -> MoneroWalletFull: + def open_wallet_data( + password: str, + nettype: MoneroNetworkType, + keys_data: bytes, + cache_data: bytes, + daemon_connection: MoneroRpcConnection = MoneroRpcConnection(), + regtest: bool = False, + ) -> MoneroWalletFull: """ Open an in-memory wallet from existing data buffers. :param str password: is the password of the wallet file to open. :param MoneroNetworkType nettype: is the wallet's network type. - :param str keys_data: contains the contents of the ".keys" file. - :param str cache_data: contains the contents of the wallet cache file (no extension). + :param bytes keys_data: contains the contents of the ".keys" file (`b""` to open without one). + :param bytes cache_data: contents of the wallet cache file, no extension (`b""` for keys only). :param MoneroRpcConnection daemon_connection: is connection information to a daemon (default = an unconnected wallet). :param bool regtest: indicates if wallet to open is a regtest wallet (optional). :returns MoneroWalletFull: reference to the wallet instance. @@ -64,20 +71,20 @@ class MoneroWalletFull(MoneroWallet): """ ... - def get_cache_file_buffer(self) -> str: + def get_cache_file_buffer(self) -> bytes: """ Get wallet cache file without using filesystem. - :returns str: Cache file buffer. + :returns bytes: Cache file buffer. """ ... - def get_keys_file_buffer(self, password: str, view_only: bool) -> str: + def get_keys_file_buffer(self, password: str, view_only: bool) -> bytes: """ Get wallet keys file without using filesystem. :param str password: The wallet password. :param bool view_only: Get view-only keys. - :returns str: Keys file buffer. + :returns bytes: Keys file buffer. """ ... diff --git a/tests/config/config.ini b/tests/config/config.ini index 8174c16..f2ee27c 100644 --- a/tests/config/config.ini +++ b/tests/config/config.ini @@ -6,6 +6,7 @@ test_notifications=True test_resets=True network_type=regtest auto_connect_timeout_ms=3000 +log_level=3 [daemon] rpc_uri=http://127.0.0.1:18081 @@ -13,6 +14,7 @@ rpc_username=rpc_daemon_user rpc_password=abc123 zmq_uri=tcp://127.0.0.1:18085 zmq_pub_uri=tcp://127.0.0.1:18086 +log_level=3 [wallet] name=test_wallet_1 diff --git a/tests/test_gen_utils.py b/tests/test_gen_utils.py index edf13cd..27d9e67 100644 --- a/tests/test_gen_utils.py +++ b/tests/test_gen_utils.py @@ -125,7 +125,6 @@ def test_reconcile_uint64_resolve_max_false_picks_lesser(self) -> None: #region reconcile values - @pytest.mark.xfail(reason="gen_utils::reconcile()'s bug", strict=True) def test_reconcile_bool_resolve_true_prefers_the_true_operand(self) -> None: # val1=False, val2=True, resolve_true=True -> should prefer the # operand that IS true, i.e. val2 @@ -133,7 +132,6 @@ def test_reconcile_bool_resolve_true_prefers_the_true_operand(self) -> None: logger.debug(f"reconcile_bool(False, True, resolve_true=True) = {result}") assert result is True - @pytest.mark.xfail(reason="gen_utils::reconcile()'s bug", strict=True) def test_reconcile_bool_resolve_true_false_prefers_the_false_operand(self) -> None: # val1=False, val2=True, resolve_true=False -> should prefer the # operand that IS false, i.e. val1 @@ -141,7 +139,6 @@ def test_reconcile_bool_resolve_true_false_prefers_the_false_operand(self) -> No logger.debug(f"reconcile_bool(False, True, resolve_true=False) = {result}") assert result is False - @pytest.mark.xfail(reason="same resolve_true bug as reconcile_bool, reproduced with the uint64 overload to show it isn't bool-specific", strict=True) def test_reconcile_uint64_resolve_true_prefers_the_true_operand(self) -> None: # val1=0 (falsy), val2=1 (truthy), resolve_true=True -> should prefer # val2 since it's the operand whose bool cast is True diff --git a/tests/test_monero_common.py b/tests/test_monero_common.py index 75f6acb..201316e 100644 --- a/tests/test_monero_common.py +++ b/tests/test_monero_common.py @@ -31,9 +31,10 @@ def test_monero_error(self) -> None: assert monero_rpc_err.code == -1 # test serializable struct - @pytest.mark.xfail(raises=TypeError, reason="Serializable struct is an abstract class") def test_serializable_struct(self) -> None: - SerializableStruct() + # SerializableStruct is abstract and cannot be instantiated directly + with pytest.raises(TypeError): + SerializableStruct() # test ssl options serialization integrity @pytest.mark.xfail(reason="TODO monero-cpp implement ssl_options::from_property_tree()", strict=True) diff --git a/tests/test_monero_daemon_interface.py b/tests/test_monero_daemon_interface.py index e669cdb..65f02e9 100644 --- a/tests/test_monero_daemon_interface.py +++ b/tests/test_monero_daemon_interface.py @@ -21,6 +21,10 @@ def daemon(self) -> MoneroDaemon: # Test interface calls + @pytest.mark.not_supported + def test_remove_listeners(self, daemon: MoneroDaemon) -> None: + daemon.remove_listeners() + @pytest.mark.not_supported def test_get_version(self, daemon: MoneroDaemon) -> None: daemon.get_version() @@ -53,6 +57,10 @@ def test_get_alt_chains(self, daemon: MoneroDaemon) -> None: def test_get_sync_info(self, daemon: MoneroDaemon) -> None: daemon.get_sync_info() + @pytest.mark.not_supported + def test_get_network_stats(self, daemon: MoneroDaemon) -> None: + daemon.get_network_stats() + @pytest.mark.not_supported def test_get_height(self, daemon: MoneroDaemon) -> None: daemon.get_height() @@ -81,6 +89,18 @@ def test_get_block_hash(self, daemon: MoneroDaemon) -> None: def test_get_block_template(self, daemon: MoneroDaemon) -> None: daemon.get_block_template("") + @pytest.mark.not_supported + def test_get_miner_data(self, daemon: MoneroDaemon) -> None: + daemon.get_miner_data() + + @pytest.mark.not_supported + def test_calculate_pow(self, daemon: MoneroDaemon) -> None: + daemon.calculate_pow(1, 1, "", "") + + @pytest.mark.not_supported + def test_add_auxiliary_pow(self, daemon: MoneroDaemon) -> None: + daemon.add_auxiliary_pow("", []) + @pytest.mark.not_supported def test_get_block_header_by_hash(self, daemon: MoneroDaemon) -> None: daemon.get_block_header_by_hash("") @@ -119,7 +139,7 @@ def test_get_blocks_by_range_chunked(self, daemon: MoneroDaemon) -> None: @pytest.mark.not_supported def test_get_block_hashes(self, daemon: MoneroDaemon) -> None: - daemon.get_block_hashes([], 0) + daemon.get_block_hashes([]) @pytest.mark.not_supported def test_submit_block(self, daemon: MoneroDaemon) -> None: @@ -189,6 +209,10 @@ def test_flush_tx_pool_2(self, daemon: MoneroDaemon) -> None: def test_flush_tx_pool_3(self, daemon: MoneroDaemon) -> None: daemon.flush_tx_pool([""]) + @pytest.mark.not_supported + def test_get_output_indices(self, daemon: MoneroDaemon) -> None: + daemon.get_output_indices("") + @pytest.mark.not_supported def test_get_outputs(self, daemon: MoneroDaemon) -> None: daemon.get_outputs([]) @@ -209,6 +233,10 @@ def test_get_peers(self, daemon: MoneroDaemon) -> None: def test_get_known_peers(self, daemon: MoneroDaemon) -> None: daemon.get_known_peers() + @pytest.mark.not_supported + def test_get_public_peers(self, daemon: MoneroDaemon) -> None: + daemon.get_public_peers() + @pytest.mark.not_supported def test_set_outgoing_peer_limit(self, daemon: MoneroDaemon) -> None: daemon.set_outgoing_peer_limit(1000) @@ -229,6 +257,10 @@ def test_set_peer_bans(self, daemon: MoneroDaemon) -> None: def test_set_peer_ban(self, daemon: MoneroDaemon) -> None: daemon.set_peer_ban(MoneroBan()) + @pytest.mark.not_supported + def test_get_peer_ban(self, daemon: MoneroDaemon) -> None: + daemon.get_peer_ban("") + @pytest.mark.not_supported def test_start_mining(self, daemon: MoneroDaemon) -> None: daemon.start_mining("", 1, False, False) @@ -249,6 +281,38 @@ def test_generate_blocks(self, daemon: MoneroDaemon) -> None: def test_prune_blockchain(self, daemon: MoneroDaemon) -> None: daemon.prune_blockchain(False) + @pytest.mark.not_supported + def test_save_blockchain(self, daemon: MoneroDaemon) -> None: + daemon.save_blockchain() + + @pytest.mark.not_supported + def test_pop_blocks(self, daemon: MoneroDaemon) -> None: + daemon.pop_blocks(1) + + @pytest.mark.not_supported + def test_flush_cache(self, daemon: MoneroDaemon) -> None: + daemon.flush_cache() + + @pytest.mark.not_supported + def test_set_bootstrap_daemon(self, daemon: MoneroDaemon) -> None: + daemon.set_bootstrap_daemon("") + + @pytest.mark.not_supported + def test_remove_bootstrap_daemon(self, daemon: MoneroDaemon) -> None: + daemon.remove_bootstrap_daemon() + + @pytest.mark.not_supported + def test_set_log_hash_rate(self, daemon: MoneroDaemon) -> None: + daemon.set_log_hash_rate(True) + + @pytest.mark.not_supported + def test_set_log_level(self, daemon: MoneroDaemon) -> None: + daemon.set_log_level(0) + + @pytest.mark.not_supported + def test_set_log_categories(self, daemon: MoneroDaemon) -> None: + daemon.set_log_categories("") + @pytest.mark.not_supported def test_check_for_update(self, daemon: MoneroDaemon) -> None: daemon.check_for_update() diff --git a/tests/test_monero_daemon_model.py b/tests/test_monero_daemon_model.py index 039b190..f532a8d 100644 --- a/tests/test_monero_daemon_model.py +++ b/tests/test_monero_daemon_model.py @@ -13,7 +13,9 @@ MoneroTxPoolStats, MoneroDaemonUpdateCheckResult, MoneroDaemonUpdateDownloadResult, MoneroFeeEstimate, MoneroDaemonInfo, MoneroNetworkType, MoneroDaemonSyncInfo, MoneroHardForkInfo, MoneroGenerateBlocksResult, MoneroTx, MoneroKeyImage, - MoneroOutput, MoneroBlockHeader, MoneroBlock, TxHeightComparator + MoneroOutput, MoneroBlockHeader, MoneroBlock, TxHeightComparator, + MoneroMinerData, MoneroDaemonNetworkStats, MoneroAuxiliaryPow, + MoneroAddAuxiliaryPowResult, MoneroGetBlocksByHashResult, MoneroGetBlockHashesResult ) from utils import BaseTestClass, AssertUtils @@ -49,7 +51,6 @@ def test_rpc_connection_deserialize(self) -> None: assert restored.proxy_uri == connection.proxy_uri assert restored.zmq_uri == connection.zmq_uri - @pytest.mark.xfail(reason="monero_rpc_connection::from_property_tree() bug", strict=True) def test_rpc_connection_priority_and_timeout_deserialize(self) -> None: # to_rapidjson_val() emits "priority" and "timeoutMs" but # from_property_tree() never read either back @@ -92,7 +93,6 @@ def test_prune_result_deserialize(self) -> None: # it never round trips (see test below) AssertUtils.assert_serialization_integrity(result) - @pytest.mark.xfail(reason="monero_prune_result::from_property_tree() bug", strict=True) def test_prune_result_is_pruned_deserialize(self) -> None: result: MoneroPruneResult = MoneroPruneResult() result.is_pruned = True @@ -177,7 +177,6 @@ def test_peer_deserialize(self) -> None: # deliberately excluded from this round trip (see test below). AssertUtils.assert_serialization_integrity(peer) - @pytest.mark.xfail(reason="monero_peer::from_property_tree() bug", strict=True) def test_peer_is_online_deserialize(self) -> None: peer: MoneroPeer = MoneroPeer() peer.is_online = True @@ -188,7 +187,6 @@ def test_peer_is_online_deserialize(self) -> None: logger.debug(f"Deserialized peer re-serialized: {restored.serialize()}") assert restored.is_online == peer.is_online - @pytest.mark.xfail(reason="monero_peer::to_rapidjson_val() bug/monero-cpp checkout", strict=True) def test_peer_connection_serialization_integrity(self) -> None: peer: MoneroPeer = MoneroPeer() peer.connection_type = MoneroConnectionType.IPV6 @@ -237,7 +235,6 @@ def test_submit_tx_result_deserialize(self) -> None: # is_good is serialized but never read back by from_property_tree() AssertUtils.assert_serialization_integrity(result) - @pytest.mark.xfail(reason="monero_submit_tx_result::from_property_tree() bug", strict=True) def test_submit_tx_result_is_good_deserialize(self) -> None: result: MoneroSubmitTxResult = MoneroSubmitTxResult() result.is_good = True @@ -282,7 +279,6 @@ def test_tx_pool_stats_deserialize(self) -> None: # and never reads it back into the map AssertUtils.assert_serialization_integrity(stats) - @pytest.mark.xfail(reason="monero_tx_pool_stats::from_property_tree() bug", strict=True) def test_tx_pool_stats_histo_deserialize(self) -> None: stats: MoneroTxPoolStats = MoneroTxPoolStats() stats.histo = {100: 1, 200: 2} @@ -356,6 +352,7 @@ def test_daemon_info_deserialize(self) -> None: info.is_busy_syncing = False info.is_synchronized = True info.is_restricted = False + info.is_regtest = True AssertUtils.assert_serialization_integrity(info) def test_daemon_info_invalid_network_type(self) -> None: @@ -374,7 +371,6 @@ def test_daemon_sync_info_deserialize(self) -> None: # reads them back (see test below) AssertUtils.assert_serialization_integrity(info) - @pytest.mark.xfail(reason="monero_daemon_sync_info::from_property_tree() bug", strict=True) def test_daemon_sync_info_peers_and_spans_deserialize(self) -> None: info: MoneroDaemonSyncInfo = MoneroDaemonSyncInfo() info.peers = [MoneroPeer()] @@ -407,6 +403,77 @@ def test_generate_blocks_result_deserialize(self) -> None: result.height = 12345 AssertUtils.assert_serialization_integrity(result) + def test_miner_data_deserialize(self) -> None: + data: MoneroMinerData = MoneroMinerData() + data.credits = 0 + data.top_block_hash = "a" * 64 + data.major_version = 16 + data.height = 3000000 + data.prev_hash = "b" * 64 + data.seed_hash = "c" * 64 + data.difficulty = "0x1f4" # daemon reports difficulty as a hex string + data.median_weight = 300000 + data.already_generated_coins = 5646232813355588 + tx: MoneroTx = MoneroTx() + tx.hash = "d" * 64 + tx.fee = 10000 + data.tx_pool_backlog = [tx] + AssertUtils.assert_serialization_integrity(data) + + def test_daemon_network_stats_deserialize(self) -> None: + stats: MoneroDaemonNetworkStats = MoneroDaemonNetworkStats() + stats.credits = 0 + stats.top_block_hash = "a" * 64 + stats.start_time = 1700000000 + stats.total_packets_in = 1234 + stats.total_bytes_in = 567890 + stats.total_packets_out = 4321 + stats.total_bytes_out = 98765 + AssertUtils.assert_serialization_integrity(stats) + + def test_auxiliary_pow_deserialize(self) -> None: + aux_pow: MoneroAuxiliaryPow = MoneroAuxiliaryPow("a" * 64, "b" * 64) + assert aux_pow.id == "a" * 64 + assert aux_pow.hash == "b" * 64 + AssertUtils.assert_serialization_integrity(aux_pow) + + def test_add_auxiliary_pow_result_deserialize(self) -> None: + result: MoneroAddAuxiliaryPowResult = MoneroAddAuxiliaryPowResult() + result.credits = 0 + result.top_block_hash = "a" * 64 + result.block_template_blob = "abcd" + result.block_hashing_blob = "ef01" + result.merkle_root = "b" * 64 + result.merkle_tree_depth = 2 + result.aux_pow = [MoneroAuxiliaryPow("c" * 64, "d" * 64)] + AssertUtils.assert_serialization_integrity(result) + + def test_get_block_hashes_result_deserialize(self) -> None: + result: MoneroGetBlockHashesResult = MoneroGetBlockHashesResult() + result.hashes = ["a" * 64, "b" * 64] + result.start_height = 100 + result.current_height = 3000000 + AssertUtils.assert_serialization_integrity(result) + + def test_get_blocks_by_hash_result_deserialize(self) -> None: + result: MoneroGetBlocksByHashResult = MoneroGetBlocksByHashResult() + result.current_height = 3000000 + # blocks are serialized but from_property_tree() never reads them back + # (monero_block has no from_property_tree of its own); see test below + AssertUtils.assert_serialization_integrity(result) + + def test_get_blocks_by_hash_result_blocks_deserialize(self) -> None: + result: MoneroGetBlocksByHashResult = MoneroGetBlocksByHashResult() + block: MoneroBlock = MoneroBlock() + block.hash = "a" * 64 + block.height = 100 + result.blocks = [block] + json_str: str = result.serialize() + logger.debug(f"Serialized get blocks by hash result: {json_str}") + assert '"blocks"' in json_str + restored: MoneroGetBlocksByHashResult = MoneroGetBlocksByHashResult.deserialize(json_str) + assert len(restored.blocks) == 0 + #endregion #region Tx / output / key image @@ -425,24 +492,15 @@ def test_output_deserialize(self) -> None: key_image.hex = "a" * 64 key_image.signature = "b" * 128 output.key_image = key_image - # ring_output_indices / stealth_public_key raise "not implemented" (see below) AssertUtils.assert_serialization_integrity(output) - def test_output_ring_output_indices_not_implemented(self) -> None: - with pytest.raises(Exception, match="not implemented"): - MoneroOutput.deserialize('{"ringOutputIndices":[1,2,3]}') - - def test_output_stealth_public_key_not_implemented(self) -> None: - with pytest.raises(Exception, match="not implemented"): - MoneroOutput.deserialize('{"stealthPublicKey":"' + "a" * 64 + '"}') - - @pytest.mark.xfail(reason="monero_output::from_property_tree() bug", strict=True) def test_output_ring_output_indices_and_stealth_public_key_deserialize(self) -> None: output: MoneroOutput = MoneroOutput() output.amount = 1000000 output.index = 5 output.ring_output_indices = [10, 20, 30] output.stealth_public_key = "a" * 64 + output.mask = "b" * 64 AssertUtils.assert_serialization_integrity(output) def test_tx_deserialize(self) -> None: @@ -455,6 +513,7 @@ def test_tx_deserialize(self) -> None: tx.is_relayed = True tx.is_confirmed = True tx.in_tx_pool = False + tx.is_locked = False tx.num_confirmations = 10 tx.unlock_time = 0 tx.last_relayed_timestamp = 1700000000 @@ -472,30 +531,20 @@ def test_tx_deserialize(self) -> None: tx.is_failed = False tx.last_failed_hash = "e" * 64 tx.max_used_block_hash = "f" * 64 - # version, inputs, outputs, outputIndices, commonTxSets, extra, - # rctSignatures, rctSigPrunable, lastFailedHeight, maxUsedBlockHeight and - # signatures are all left unimplemented in from_property_tree() (see below) + # mixin, rctSignatures, rctSigPrunable and signatures are still left + # unimplemented in from_property_tree() (see below) AssertUtils.assert_serialization_integrity(tx) @pytest.mark.parametrize("json_fragment", [ - '{"version":1}', '{"mixin":5}', - '{"inputs":[]}', - '{"outputs":[]}', - '{"outputIndices":[1]}', - '{"commonTxSets":"x"}', - '{"extra":[1,2,3]}', '{"rctSignatures":"x"}', '{"rctSigPrunable":"x"}', - '{"lastFailedHeight":1}', - '{"maxUsedBlockHeight":1}', '{"signatures":["x"]}', ]) def test_tx_unimplemented_fields(self, json_fragment: str) -> None: with pytest.raises(Exception, match="not implemented"): MoneroTx.deserialize(json_fragment) - @pytest.mark.xfail(reason="monero_tx::from_property_tree() bug", strict=True) def test_tx_version_common_tx_sets_last_failed_and_max_used_block_height_deserialize(self) -> None: tx: MoneroTx = MoneroTx() tx.version = 2 @@ -504,19 +553,16 @@ def test_tx_version_common_tx_sets_last_failed_and_max_used_block_height_deseria tx.max_used_block_height = 200 AssertUtils.assert_serialization_integrity(tx) - @pytest.mark.xfail(reason="monero_tx::from_property_tree() bug", strict=True) def test_tx_ring_size_deserialize(self) -> None: tx: MoneroTx = MoneroTx() tx.ring_size = 16 AssertUtils.assert_serialization_integrity(tx) - @pytest.mark.xfail(reason="monero_tx::from_property_tree() bug", strict=True) def test_tx_extra_deserialize(self) -> None: tx: MoneroTx = MoneroTx() tx.extra = [1, 2, 3, 255] AssertUtils.assert_serialization_integrity(tx) - @pytest.mark.xfail(reason="monero_tx::from_property_tree() bug", strict=True) def test_tx_inputs_outputs_and_output_indices_deserialize(self) -> None: tx: MoneroTx = MoneroTx() tx.output_indices = [100, 101] @@ -602,7 +648,6 @@ def test_block_merge(self) -> None: a.merge(b) assert a.hex == "deadbeef" - @pytest.mark.xfail(reason="merge_tx() dereferences m_hash unconditionally (boost::optional UB when unset)", strict=True) def test_block_merge_txs_with_unset_hash_are_kept_distinct(self) -> None: script: LiteralString = ( "import monero, sys\n" @@ -646,7 +691,6 @@ def test_tx_merge(self) -> None: a.merge(b) assert a.num_confirmations == 5 - @pytest.mark.xfail(reason="gen_utils::reconcile() bug", strict=True) def test_tx_merge_is_confirmed_can_become_true(self) -> None: a: MoneroTx = MoneroTx() a.hash = "a" * 64 @@ -656,7 +700,6 @@ def test_tx_merge_is_confirmed_can_become_true(self) -> None: a.merge(b) assert a.is_confirmed is True - @pytest.mark.xfail(reason="same gen_utils::reconcile() bug", strict=True) def test_tx_merge_is_double_spend_seen_can_become_true(self) -> None: a: MoneroTx = MoneroTx() a.hash = "a" * 64 @@ -667,7 +710,6 @@ def test_tx_merge_is_double_spend_seen_can_become_true(self) -> None: a.merge(b) assert a.is_double_spend_seen is True - @pytest.mark.xfail(reason="same gen_utils::reconcile() bug", strict=True) def test_tx_merge_in_tx_pool_can_become_true(self) -> None: a: MoneroTx = MoneroTx() a.hash = "a" * 64 @@ -678,6 +720,16 @@ def test_tx_merge_in_tx_pool_can_become_true(self) -> None: a.merge(b) assert a.in_tx_pool is True + def test_tx_merge_is_locked_can_become_false(self) -> None: + a: MoneroTx = MoneroTx() + a.hash = "a" * 64 + a.is_confirmed = True + a.is_locked = False # self: already unlocked + b: MoneroTx = a.copy() + b.is_locked = True # other: still locked + a.merge(b) + assert a.is_locked is False + def test_key_image_copy(self) -> None: key_image: MoneroKeyImage = MoneroKeyImage() key_image.hex = "a" * 64 @@ -719,7 +771,6 @@ def test_output_merge(self) -> None: assert a.key_image is not None assert a.key_image.hex == "a" * 64 - @pytest.mark.xfail(reason="monero_tx::merge() bug", strict=True) def test_tx_merge_extra_and_output_indices(self) -> None: a: MoneroTx = MoneroTx() a.hash = "a" * 64 @@ -731,7 +782,6 @@ def test_tx_merge_extra_and_output_indices(self) -> None: assert a.extra == [1, 2, 3, 255] assert a.output_indices == [100, 101] - @pytest.mark.xfail(reason="monero_output::merge() bug", strict=True) def test_output_merge_ring_output_indices_and_stealth_public_key(self) -> None: a: MoneroOutput = MoneroOutput() a.amount = 1000000 @@ -739,9 +789,11 @@ def test_output_merge_ring_output_indices_and_stealth_public_key(self) -> None: b: MoneroOutput = a.copy() # preserves the (unset) tx reference, so merge won't recurse into tx merge b.ring_output_indices = [10, 20, 30] b.stealth_public_key = "a" * 64 - a.merge(b) # a.ring_output_indices/stealth_public_key are unset -> merge should adopt b's + b.mask = "b" * 64 + a.merge(b) # a's fields are unset -> merge should adopt b's assert a.ring_output_indices == [10, 20, 30] assert a.stealth_public_key == "a" * 64 + assert a.mask == "b" * 64 def test_tx_lt_height_comparator(self) -> None: tx_a: MoneroTx = MoneroTx() diff --git a/tests/test_monero_daemon_rpc.py b/tests/test_monero_daemon_rpc.py index 6a4a414..c66ee50 100644 --- a/tests/test_monero_daemon_rpc.py +++ b/tests/test_monero_daemon_rpc.py @@ -12,7 +12,9 @@ MoneroHardForkInfo, MoneroAltChain, MoneroTx, MoneroSubmitTxResult, MoneroTxPoolStats, MoneroBan, MoneroTxConfig, MoneroDestination, MoneroWalletRpc, MoneroKeyImageSpentStatus, MoneroRpcConnection, - MoneroOutputHistogramEntry, MoneroOutputDistributionEntry, MoneroFeeEstimate + MoneroOutputHistogramEntry, MoneroOutputDistributionEntry, MoneroFeeEstimate, + MoneroMinerData, MoneroDaemonNetworkStats, MoneroAuxiliaryPow, + MoneroAddAuxiliaryPowResult, MoneroGetBlocksByHashResult, MoneroGetBlockHashesResult ) from utils import ( TestUtils as Utils, TestContext, BinaryBlockContext, RpcConnectionUtils, @@ -74,12 +76,9 @@ def test_offline_daemon(self) -> None: assert not daemon.is_connected() # call to any daemon method should throw network error - try: + with pytest.raises(Exception) as exc_info: daemon.get_height() - raise Exception("Should have thrown an exception") - except Exception as e: - e_msg: str = str(e) - assert e_msg == RpcConnectionUtils.NETWORK_ERROR_MSG, e_msg + assert str(exc_info.value) == RpcConnectionUtils.NETWORK_ERROR_MSG # Can get the daemon's version @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -208,9 +207,29 @@ def test_get_block_by_hash(self, daemon: MoneroDaemonRpc) -> None: # Can get blocks by hash which includes transactions (binary) @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") - @pytest.mark.skip(reason="Not implemented") - def test_get_blocks_by_hash_binary(self) -> None: - raise NotImplementedError("Not implemented") + def test_get_blocks_by_hash_binary(self, daemon: MoneroDaemonRpc) -> None: + chain_height: int = daemon.get_height() + genesis_hash: str = daemon.get_block_hash(0) + + # a non-zero start height resumes from there and ignores the hash list + resume_height: int = chain_height - 5 + result: MoneroGetBlocksByHashResult = daemon.get_blocks_by_hash([genesis_hash], resume_height, False, 0) + assert result.current_height is not None and result.current_height >= chain_height + assert len(result.blocks) > 0 + for i, block in enumerate(result.blocks): + assert block.height == resume_height + i + BlockUtils.test_block(block, self.BINARY_BLOCK_CTX) + + # a genesis-terminated history resumes from height 0, capped by max_block_count + result = daemon.get_blocks_by_hash([genesis_hash], 0, False, 10) + assert 0 < len(result.blocks) <= 10 + assert result.blocks[0].height == 0 + + # noop when the first hash is already the tip + tip: MoneroBlockHeader = daemon.get_last_block_header() + assert tip.hash is not None + result = daemon.get_blocks_by_hash([tip.hash, genesis_hash], 0, False, 0) + assert len(result.blocks) == 0 # Can get a block by height @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -324,10 +343,24 @@ def test_get_genesis_block_by_range_chunked(self, daemon: MoneroDaemonRpc) -> No # Can get block hashes (binary) @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") - @pytest.mark.skip(reason="Binary request not implemented") - def test_get_block_ids_binary(self) -> None: - # get_hashes.bin - raise NotImplementedError("Binary request not implemented") + def test_get_block_hashes_binary(self, daemon: MoneroDaemonRpc) -> None: + chain_height: int = daemon.get_height() + genesis_hash: str = daemon.get_block_hash(0) + + # a genesis-terminated history returns the whole chain from height 0 + result: MoneroGetBlockHashesResult = daemon.get_block_hashes([genesis_hash]) + assert result.start_height == 0 + assert result.current_height is not None and result.current_height >= chain_height + assert len(result.hashes) > 0 + assert result.hashes[0] == genesis_hash + for hash_str in result.hashes: + assert len(hash_str) == 64 + + # only the tip comes back when it is already the first hash + tip: MoneroBlockHeader = daemon.get_last_block_header() + assert tip.hash is not None + result = daemon.get_block_hashes([tip.hash, genesis_hash]) + assert result.hashes == [tip.hash] # Can get a transaction by hash and without pruning @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -353,12 +386,9 @@ def test_get_tx_by_hash(self, daemon: MoneroDaemonRpc) -> None: TxUtils.test_tx(tx, ctx) # fetch invalid hash - try: + with pytest.raises(Exception) as exc_info: daemon.get_tx("invalid tx hash") - raise Exception("fail") - except Exception as e: - e_msg: str = str(e) - assert "Invalid transaction hash" == e_msg, e_msg + assert str(exc_info.value) == "Invalid transaction hash" # Can get transactions by hashes with and without pruning @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -404,12 +434,9 @@ def test_get_txs_by_hashes(self, daemon: MoneroDaemonRpc, wallet: MoneroWalletRp assert num_txs == len(txs) # fetch invalid hash - try: + with pytest.raises(Exception) as exc_info: daemon.get_txs(["invalid tx hash"]) - raise Exception("fail") - except Exception as e: - e_msg: str = str(e) - assert "Invalid transaction hash" == e_msg, e_msg + assert str(exc_info.value) == "Invalid transaction hash" # Can get transactions by hashes that are in the transaction pool @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -476,12 +503,9 @@ def test_get_tx_hex_by_hash(self, daemon: MoneroDaemonRpc) -> None: assert len(tx_hex) >= len(pruned_tx_hex) # fetch invalid hash - try: + with pytest.raises(Exception) as exc_info: daemon.get_tx_hex("invalid tx hash") - raise Exception("Should have failed") - except Exception as e: - e_msg: str = str(e) - assert e_msg == "Invalid transaction hash", e_msg + assert str(exc_info.value) == "Invalid transaction hash" # Can get transaction hexes by hashes with and without pruning @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -506,12 +530,9 @@ def test_get_tx_hexes_by_hashes(self, daemon: MoneroDaemonRpc) -> None: # fetch invalid hash tx_hashes.append("invalid tx hash") - try: + with pytest.raises(Exception) as exc_info: daemon.get_tx_hexes(tx_hashes) - raise Exception("Should have failed") - except Exception as e: - e_msg: str = str(e) - assert e_msg == "Invalid transaction hash", e_msg + assert str(exc_info.value) == "Invalid transaction hash" # Can get the miner tx sum @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -761,6 +782,7 @@ def test_get_output_distribution(self, daemon: MoneroDaemonRpc) -> None: def test_get_general_information(self, daemon: MoneroDaemonRpc) -> None: info: MoneroDaemonInfo = daemon.get_info() DaemonUtils.test_info(info) + assert info.is_regtest == Utils.REGTEST # Can get sync information @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -810,12 +832,9 @@ def test_set_download_bandwidth(self, daemon: MoneroDaemonRpc) -> None: assert init_val == reset_val # test invalid limits - try: + with pytest.raises(Exception) as exc_info: daemon.set_download_limit(0) - raise Exception("Should have thrown error on invalid input") - except Exception as e: - e_msg: str = str(e) - assert "Download limit must be an integer greater than 0" == e_msg, e_msg + assert str(exc_info.value) == "Download limit must be an integer greater than 0" assert daemon.get_download_limit() == init_val @@ -832,12 +851,9 @@ def test_set_upload_bandwidth(self, daemon: MoneroDaemonRpc) -> None: assert init_val == reset_val # test invalid limits - try: + with pytest.raises(Exception) as exc_info: daemon.set_upload_limit(0) - raise Exception("Should have thrown error on invalid input") - except Exception as e: - e_msg: str = str(e) - assert "Upload limit must be an integer greater than 0" == e_msg, e_msg + assert str(exc_info.value) == "Upload limit must be an integer greater than 0" assert init_val == daemon.get_upload_limit() @@ -1026,12 +1042,9 @@ def test_submit_mined_block(self, daemon: MoneroDaemonRpc) -> None: # TODO monero rpc: way to get mining nonce when found in order to submit? # try to submit block hashing blob without nonce - try: + with pytest.raises(Exception) as exc_info: daemon.submit_block(template.block_template_blob) - raise Exception("Should have thrown error") - except Exception as e: - e_msg: str = str(e) - assert "Block not accepted" == e_msg, e_msg + assert str(exc_info.value) == "Block not accepted" # Can prune the blockchain @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -1066,16 +1079,12 @@ def test_download_update(self, daemon: MoneroDaemonRpc) -> None: DaemonUtils.test_update_download_result(result, path) # test invalid path + # TODO monerod: an invalid path causes a 500 in daemon rpc rather than a clean error if result.is_update_available: try: daemon.download_update("./ohhai/there") - raise Exception("Should have thrown error") except Exception as e: - e_msg: str = str(e) - if e_msg != "Should have thrown error": - logger.warning(e_msg) - #assert e_msg != "Should have thrown error", e_msg - # TODO monerod: this causes a 500 in daemon rpc + logger.warning(str(e)) # Can be stopped @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -1087,13 +1096,181 @@ def test_stop(self, daemon: MoneroDaemonRpc) -> None: # give the daemon time to shut down time.sleep(Utils.SYNC_PERIOD_IN_MS / 1000) - # try to interact with the daemon - try: + # try to interact with the stopped daemon + with pytest.raises(Exception): daemon.get_height() - raise Exception("Should have thrown error") + + # Can get the data needed to mine a block + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_miner_data(self, daemon: MoneroDaemonRpc) -> None: + data: MoneroMinerData = daemon.get_miner_data() + DaemonUtils.test_miner_data(data) + + # Can calculate a block's proof-of-work hash + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_calculate_pow(self, daemon: MoneroDaemonRpc) -> None: + data: MoneroMinerData = daemon.get_miner_data() + template: MoneroBlockTemplate = daemon.get_block_template(Utils.ADDRESS) + assert data.major_version is not None + assert data.height is not None + assert data.seed_hash is not None + assert template.block_template_blob is not None + + pow_hash: str = daemon.calculate_pow(data.major_version, data.height, template.block_template_blob, data.seed_hash) + assert len(pow_hash) == 64 + + with pytest.raises(Exception) as exc_info: + daemon.calculate_pow(data.major_version, data.height, "", data.seed_hash) + assert str(exc_info.value) == "Must provide a block blob to hash" + + # Can add auxiliary proof-of-work to a block template + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_add_auxiliary_pow(self, daemon: MoneroDaemonRpc) -> None: + template: MoneroBlockTemplate = daemon.get_block_template(Utils.ADDRESS) + assert template.block_template_blob is not None + + aux_pow: MoneroAuxiliaryPow = MoneroAuxiliaryPow("11" * 32, "22" * 32) + result: MoneroAddAuxiliaryPowResult = daemon.add_auxiliary_pow(template.block_template_blob, [aux_pow]) + assert result.block_template_blob is not None and len(result.block_template_blob) > 0 + assert result.block_hashing_blob is not None and len(result.block_hashing_blob) > 0 + assert result.merkle_root is not None and len(result.merkle_root) == 64 + assert result.merkle_tree_depth is not None and result.merkle_tree_depth >= 0 + assert len(result.aux_pow) == 1 + assert result.aux_pow[0].id is not None and len(result.aux_pow[0].id) == 64 + assert result.aux_pow[0].hash is not None and len(result.aux_pow[0].hash) == 64 + + with pytest.raises(Exception) as exc_info: + daemon.add_auxiliary_pow(template.block_template_blob, []) + assert str(exc_info.value) == "Must provide auxiliary proof of work to add" + + # Can get the global output indices of a transaction + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_output_indices(self, daemon: MoneroDaemonRpc) -> None: + tx_hashes: list[str] = DaemonUtils.get_confirmed_tx_hashes(daemon) + assert len(tx_hashes) > 0, "No confirmed txs found" + + for tx_hash in tx_hashes: + indices: list[int] = daemon.get_output_indices(tx_hash) + assert len(indices) > 0 + for index in indices: + assert index >= 0 + + with pytest.raises(Exception) as exc_info: + daemon.get_output_indices("") + assert str(exc_info.value) == "Must provide a transaction hash" + + # Can get network (bandwidth) statistics + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_network_stats(self, daemon: MoneroDaemonRpc) -> None: + stats: MoneroDaemonNetworkStats = daemon.get_network_stats() + DaemonUtils.test_network_stats(stats) + + # Can get public nodes known to the daemon + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_public_peers(self, daemon: MoneroDaemonRpc) -> None: + peers: list[MoneroPeer] = daemon.get_public_peers() + if Utils.REGTEST: + assert len(peers) == 0 + for peer in peers: + DaemonUtils.test_known_peer(peer, False) + + # Can query the ban status of a single peer + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_get_peer_ban(self, daemon: MoneroDaemonRpc) -> None: + host: str = "192.168.1.60" + ban: MoneroBan = MoneroBan() + ban.host = host + ban.is_banned = True + ban.seconds = 60 + daemon.set_peer_ban(ban) + + banned: MoneroBan = daemon.get_peer_ban(host) + assert banned.is_banned is True + assert banned.seconds is not None and banned.seconds > 0 + + assert daemon.get_peer_ban("192.168.1.61").is_banned is False + + with pytest.raises(Exception) as exc_info: + daemon.get_peer_ban("") + assert str(exc_info.value) == "Must provide an address to check the ban status of" + + # Can remove all listeners at once + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_remove_daemon_listeners(self, daemon: MoneroDaemonRpc) -> None: + listeners: list[DaemonNotificationCollector] = [DaemonNotificationCollector(daemon), DaemonNotificationCollector(daemon)] + for listener in listeners: + daemon.add_listener(listener) + assert len(daemon.get_listeners()) == 2 + + daemon.remove_listeners() + assert len(daemon.get_listeners()) == 0 + + # Can flush the blockchain to disk + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_save_blockchain(self, daemon: MoneroDaemonRpc) -> None: + daemon.save_blockchain() + + # Can flush the daemon's invalid block and tx caches + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_flush_cache(self, daemon: MoneroDaemonRpc) -> None: + daemon.flush_cache() + daemon.flush_cache(True) + + # Can disable the bootstrap daemon + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_remove_bootstrap_daemon(self, daemon: MoneroDaemonRpc) -> None: + daemon.remove_bootstrap_daemon() + + # Can set the daemon's log level + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_set_log_level(self, daemon: MoneroDaemonRpc) -> None: + try: + for i in range(0, 5): + daemon.set_log_level(i) + + for level in (-1, 5): + with pytest.raises(Exception) as exc_info: + daemon.set_log_level(level) + assert str(exc_info.value) == "Log level must be an integer between 0 and 4" + finally: + # restore the level from docker-compose so later tests keep full logs + daemon.set_log_level(Utils.DAEMON_LOG_LEVEL) + + # Can set the daemon's log categories + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_set_log_categories(self, daemon: MoneroDaemonRpc) -> None: + try: + categories: str = daemon.set_log_categories("*:WARNING") + assert isinstance(categories, str) + finally: + # re-applying the configured level also resets categories to its mapping + daemon.set_log_level(Utils.DAEMON_LOG_LEVEL) + + # Can toggle the mining hash rate in the daemon log + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_set_log_hash_rate(self, daemon: MoneroDaemonRpc) -> None: + try: + daemon.set_log_hash_rate(True) + daemon.set_log_hash_rate(False) except Exception as e: - e_msg: str = str(e) - assert e_msg != "Should have thrown error", e_msg + # daemon rejects the call while not mining + logger.warning(f"set_log_hash_rate: {e}") + + # Can pop blocks from the top of the chain + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + @pytest.mark.skipif(Utils.REGTEST is False, reason="REGTEST disabled") + def test_pop_blocks(self, daemon: MoneroDaemonRpc) -> None: + # stop mining so it doesn't race the pop, then restore the shared chain afterwards + MiningUtils.try_stop_mining(daemon) + height: int = daemon.get_height() + + assert daemon.pop_blocks(1) == height - 1 + MiningUtils.generate_blocks(Utils.MINING_ADDRESS, 1, daemon) + assert daemon.get_height() == height + + with pytest.raises(Exception) as exc_info: + daemon.pop_blocks(0) + assert str(exc_info.value) == "Must provide a number of blocks to pop greater than 0" #endregion diff --git a/tests/test_monero_rpc_connection.py b/tests/test_monero_rpc_connection.py index 5bfa927..6a537ca 100644 --- a/tests/test_monero_rpc_connection.py +++ b/tests/test_monero_rpc_connection.py @@ -150,35 +150,20 @@ def test_set_invalid_credentials(self) -> None: # create test connection connection: MoneroRpcConnection = MoneroRpcConnection(Utils.DAEMON_RPC_URI, Utils.DAEMON_RPC_USERNAME, Utils.DAEMON_RPC_PASSWORD) - # test connection username property assign - try: + # username / password are read-only properties (set via set_credentials()) + with pytest.raises(AttributeError, match="object has no setter"): connection.username = "user" # type: ignore - except AttributeError as e: - err_msg: str = str(e) - assert "object has no setter" in err_msg, err_msg - # test connection password property assign - try: + with pytest.raises(AttributeError, match="object has no setter"): connection.password = "abc123" # type: ignore - except AttributeError as e: - err_msg: str = str(e) - assert "object has no setter" in err_msg, err_msg # set invalid username - try: + with pytest.raises(RuntimeError, match="username cannot be empty because password is not empty"): connection.set_credentials("", "abc123") - raise Exception("Should have thrown") - except Exception as e: - e_msg: str = str(e) - assert e_msg == "username cannot be empty because password is not empty", e_msg # set invalid password - try: + with pytest.raises(RuntimeError, match="password cannot be empty because username is not empty"): connection.set_credentials("user", "") - raise Exception("Should have thrown") - except Exception as e: - e_msg: str = str(e) - assert e_msg == "password cannot be empty because username is not empty", e_msg # test connection assert connection.username == Utils.DAEMON_RPC_USERNAME @@ -233,12 +218,10 @@ def test_send_json_request(self, node_connection: MoneroRpcConnection) -> None: logger.debug(f"JSON-RPC response {result}") # test invalid json rpc method - try: + with pytest.raises(MoneroRpcError) as exc_info: node_connection.send_json_request("invalid_method") - except MoneroRpcError as e: - e_msg: str = str(e) - assert e_msg == "Method not found", e_msg - assert e.code == -32601 + assert str(exc_info.value) == "Method not found" + assert exc_info.value.code == -32601 # Can send binary request @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -252,10 +235,9 @@ def test_send_binary_request(self, node_connection: MoneroRpcConnection) -> None logger.debug(f"Deserialized binary response: {StringUtils.prettify(json_result)}") # test invalid binary method - try: + with pytest.raises(MoneroRpcError) as exc_info: node_connection.send_binary_request("invalid_method") - except MoneroRpcError as e: - assert e.code == 404 + assert exc_info.value.code == 404 # Can send path request @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -266,9 +248,8 @@ def test_send_path_request(self, node_connection: MoneroRpcConnection) -> None: logger.debug(f"Path response {result}") # test invalid path method - try: + with pytest.raises(MoneroRpcError) as exc_info: node_connection.send_path_request("invalid_method") - except MoneroRpcError as e: - assert e.code == 404 + assert exc_info.value.code == 404 #endregion diff --git a/tests/test_monero_utils.py b/tests/test_monero_utils.py index 6ce5439..4420dab 100644 --- a/tests/test_monero_utils.py +++ b/tests/test_monero_utils.py @@ -433,22 +433,18 @@ def test_get_payment_uri(self, config: TestMoneroUtils.Config) -> None: def test_payment_uri_invalid_network_type(self, config: TestMoneroUtils.Config) -> None: address: str = config.testnet.primary_address_1 tx_config: MoneroTxConfig = WalletUtils.build_payment_uri_config(address) - try: + with pytest.raises(Exception) as exc_info: MoneroUtils.get_payment_uri(tx_config) - raise Exception("Should have failed") - except Exception as e: - WalletErrorUtils.test_invalid_address_error(e, address) + WalletErrorUtils.test_invalid_address_error(exc_info.value, address) # Test deprecated standalone payment id def test_payment_uri_deprecated_payment_uri(self, config: TestMoneroUtils.Config) -> None: address: str = config.testnet.primary_address_1 tx_config: MoneroTxConfig = WalletUtils.build_payment_uri_config(address) tx_config.payment_id = "03284e41c342f03603284e41c342f03603284e41c342f03603284e41c342f036" - try: + with pytest.raises(Exception) as exc_info: MoneroUtils.get_payment_uri(tx_config, MoneroNetworkType.TESTNET) - raise Exception("Should have failed") - except Exception as e: - WalletErrorUtils.test_deprecated_payment_id_error(e) + WalletErrorUtils.test_deprecated_payment_id_error(exc_info.value) # Can get version def test_get_version(self) -> None: @@ -459,8 +455,7 @@ def test_get_version(self) -> None: # Can get ring size def test_get_ring_size(self) -> None: size: int = MoneroUtils.get_ring_size() - # TODO monero-cpp update ring size to 16 - assert size == 12 + assert size == 16 #endregion @@ -787,7 +782,6 @@ def run_batch(rounds: int) -> None: # pin down normal allocator noise assert growth_mb < 100, f"RSS grew {growth_mb:.1f} MB after freeing 200,000 tx objects -- possible leak" - @pytest.mark.xfail(reason="monero_utils::free(block)/free(tx) dereference their argument without a null check and segfault when it's None", strict=True) def test_free_none_does_not_crash(self) -> None: script: str = "import monero\nmonero.MoneroUtils.free(None)\n" result: subprocess.CompletedProcess[str] = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) diff --git a/tests/test_monero_wallet_common.py b/tests/test_monero_wallet_common.py index 11d1b7f..fd1c6a5 100644 --- a/tests/test_monero_wallet_common.py +++ b/tests/test_monero_wallet_common.py @@ -4,7 +4,7 @@ import logging from typing import override -from time import sleep +from time import sleep, monotonic from random import shuffle from configparser import ConfigParser from abc import abstractmethod @@ -238,16 +238,13 @@ def after_each(self, request: pytest.FixtureRequest) -> None: @pytest.mark.flaky(reruns=5, reruns_delay=5, only_rerun=[]) def test_validate_inputs_sending_funds(self, wallet: MoneroWallet) -> None: # try sending with invalid address - try: - tx_config: MoneroTxConfig = MoneroTxConfig() - tx_config.address = "my invalid address" - tx_config.account_index = 0 - tx_config.amount = TxWalletUtils.MAX_FEE + tx_config: MoneroTxConfig = MoneroTxConfig() + tx_config.address = "my invalid address" + tx_config.account_index = 0 + tx_config.amount = TxWalletUtils.MAX_FEE + with pytest.raises(Exception) as exc_info: wallet.create_tx(tx_config) - raise Exception("Should have thrown") - except Exception as e: - if str(e) != "Invalid destination address": - raise + assert str(exc_info.value) == "Invalid destination address" # Can sync with txs in the pool sent from/to the same account # TODO this test fails because wallet does not recognize pool tx sent from/to same account @@ -394,20 +391,16 @@ def test_send_to_self(self, wallet: MoneroWallet) -> None: # TODO (monero-project): sending funds to self # with integrated subaddress throws error: https://github.com/monero-project/monero/issues/8380 - try: - tx_config: MoneroTxConfig = MoneroTxConfig() - tx_config.account_index = 0 - subaddress: MoneroSubaddress = wallet.get_subaddress(0, 1) - assert subaddress.address is not None - address: str = subaddress.address - tx_config.address = MoneroUtils.get_integrated_address(TestUtils.NETWORK_TYPE, address, '').integrated_address - tx_config.amount = amount - tx_config.relay = True + tx_config: MoneroTxConfig = MoneroTxConfig() + tx_config.account_index = 0 + subaddress: MoneroSubaddress = wallet.get_subaddress(0, 1) + assert subaddress.address is not None + address: str = subaddress.address + tx_config.address = MoneroUtils.get_integrated_address(TestUtils.NETWORK_TYPE, address, '').integrated_address + tx_config.amount = amount + tx_config.relay = True + with pytest.raises(Exception, match="Total received by"): wallet.create_tx(tx_config) - raise Exception("Should have failed sending to self with integrated subaddress") - except Exception as e: - if "Total received by" not in str(e): - raise # send funds to self tx_config = MoneroTxConfig() @@ -525,12 +518,10 @@ def test_send_with_payment_id(self, wallet: MoneroWallet) -> None: integrated_address: MoneroIntegratedAddress = wallet.get_integrated_address() assert integrated_address.payment_id is not None payment_id: str = integrated_address.payment_id - try: + msg = "Standalone payment IDs are obsolete. Use subaddresses or integrated addresses instead" + with pytest.raises(Exception) as exc_info: WalletSendUtils.test_send_to_single(wallet, False, None, f"{payment_id}{payment_id}{payment_id}") - raise Exception("Should have thrown") - except Exception as e: - msg = "Standalone payment IDs are obsolete. Use subaddresses or integrated addresses instead" - assert msg == str(e) + assert str(exc_info.value) == msg # Can send to an address with split transactions @pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled") @@ -677,7 +668,7 @@ def test_sweep_dust(self, wallet: MoneroWallet) -> None: # Can update a locked tx sent from/to the same account as blocks are added to the chain @pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled") @pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled") - @pytest.mark.flaky(reruns=5, reruns_delay=10, only_rerun=["BUSY", r"Cannot reconcile integrals:.*m_is_incoming"]) + @pytest.mark.flaky(reruns=5, reruns_delay=10, only_rerun=["Daemon is busy", r"Cannot reconcile integrals:.*m_is_incoming"]) def test_update_locked_same_account(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: config: MoneroTxConfig = MoneroTxConfig() config.address = wallet.get_primary_address() @@ -691,7 +682,7 @@ def test_update_locked_same_account(self, daemon: MoneroDaemonRpc, wallet: Moner @pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled") @pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled") @pytest.mark.skipif(TestUtils.LITE_MODE, reason="LITE_MODE enabled") - @pytest.mark.flaky(reruns=5, reruns_delay=10, only_rerun=["BUSY", r"Cannot reconcile integrals:.*m_is_incoming"]) + @pytest.mark.flaky(reruns=5, reruns_delay=10, only_rerun=["Daemon is busy", r"Cannot reconcile integrals:.*m_is_incoming"]) def test_update_locked_same_account_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: config: MoneroTxConfig = MoneroTxConfig() config.address = wallet.get_primary_address() @@ -706,7 +697,7 @@ def test_update_locked_same_account_split(self, daemon: MoneroDaemonRpc, wallet: @pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled") @pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled") @pytest.mark.skipif(TestUtils.LITE_MODE, reason="LITE_MODE enabled") - @pytest.mark.flaky(reruns=5, reruns_delay=10, only_rerun=["BUSY", r"Cannot reconcile integrals:.*m_is_incoming"]) + @pytest.mark.flaky(reruns=5, reruns_delay=10, only_rerun=["Daemon is busy", r"Cannot reconcile integrals:.*m_is_incoming"]) def test_update_locked_different_accounts(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: config: MoneroTxConfig = MoneroTxConfig() config.address = wallet.get_subaddress(1, 0).address @@ -720,7 +711,7 @@ def test_update_locked_different_accounts(self, daemon: MoneroDaemonRpc, wallet: @pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled") @pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled") @pytest.mark.skipif(TestUtils.LITE_MODE, reason="LITE_MODE enabled") - @pytest.mark.flaky(reruns=5, reruns_delay=10, only_rerun=["BUSY", r"Cannot reconcile integrals:.*m_is_incoming"]) + @pytest.mark.flaky(reruns=5, reruns_delay=10, only_rerun=["Daemon is busy", r"Cannot reconcile integrals:.*m_is_incoming"]) def test_update_locked_different_accounts_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: config: MoneroTxConfig = MoneroTxConfig() config.address = wallet.get_subaddress(1, 0).address @@ -769,24 +760,18 @@ def test_create_wallet_random(self) -> None: self._close_wallet(wallet) # attempt to create wallet at same path - try: - config = MoneroWalletConfig() - config.path = path + config = MoneroWalletConfig() + config.path = path + with pytest.raises(Exception) as exc_info: self._create_wallet(config) - raise Exception("Should have thrown error") - except Exception as e: - e_msg: str = str(e) - assert "Wallet already exists: " + path == e_msg, e_msg + assert str(exc_info.value) == "Wallet already exists: " + path # attempt to create wallet with unknown language - try: - config = MoneroWalletConfig() - config.language = "english" + config = MoneroWalletConfig() + config.language = "english" + with pytest.raises(Exception) as exc_info: 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 + assert str(exc_info.value) == "Unknown language: english" # Can create a wallet from a seed @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -814,24 +799,19 @@ def test_create_wallet_from_seed(self, wallet: MoneroWallet, test_config: BaseTe self._close_wallet(w) # attempt to create wallet with two missing words - try: - config = MoneroWalletConfig() - config.seed = test_config.seed - config.restore_height = TestUtils.FIRST_RECEIVE_HEIGHT + config = MoneroWalletConfig() + config.seed = test_config.seed + config.restore_height = TestUtils.FIRST_RECEIVE_HEIGHT + with pytest.raises(Exception) as exc_info: self._create_wallet(config) - except Exception as e: - e_msg: str = str(e) - assert "Invalid mnemonic" == e_msg, e_msg + assert str(exc_info.value) == "Invalid mnemonic" # attempt to create wallet at same path - try: - config = MoneroWalletConfig() - config.path = path + config = MoneroWalletConfig() + config.path = path + with pytest.raises(Exception) as exc_info: self._create_wallet(config) - raise Exception("Should have thrown error") - except Exception as e: - e_msg: str = str(e) - assert "Wallet already exists: " + path == e_msg, e_msg + assert str(exc_info.value) == "Wallet already exists: " + path # Can create a wallet from a seed with offset @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -908,14 +888,11 @@ def test_create_wallet_from_keys(self, daemon: MoneroDaemonRpc, wallet: MoneroWa self._close_wallet(w) # attempt to create wallet at same path - try: - config = MoneroWalletConfig() - config.path = path + config = MoneroWalletConfig() + config.path = path + with pytest.raises(Exception) as exc_info: self._create_wallet(config) - raise Exception("Should have thrown error") - except Exception as e: - e_msg: str = str(e) - assert "Wallet already exists: " + path == e_msg, e_msg + assert str(exc_info.value) == "Wallet already exists: " + path # Can create wallets with subaddress lookahead @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -1044,11 +1021,8 @@ def test_set_daemon_connection(self) -> None: # attempt to sync try: - wallet.sync() - raise Exception("Exception expected") - except Exception as e: - e_msg: str = str(e) - assert "Wallet is not connected to daemon" == e_msg, e_msg + with pytest.raises(Exception, match="Wallet is not connected to daemon"): + wallet.sync() finally: self._close_wallet(wallet) @@ -1142,19 +1116,14 @@ def test_get_address_indices(self, wallet: MoneroWallet) -> None: # test valid but unfound address non_wallet_address: str = WalletTestUtils.get_external_wallet_address() - try: + with pytest.raises(Exception) as exc_info: wallet.get_address_index(non_wallet_address) - raise Exception("Should have thrown exception") - except Exception as e: - e_msg: str = str(e) - assert "Address doesn't belong to the wallet" == e_msg, e_msg + assert str(exc_info.value) == "Address doesn't belong to the wallet" # test invalid address - try: + with pytest.raises(Exception) as exc_info: wallet.get_address_index("this is definitely not an address") - raise Exception("Should have thrown exception") - except Exception as e: - WalletErrorUtils.test_invalid_address_error(e) + WalletErrorUtils.test_invalid_address_error(exc_info.value) # Can decode an integrated address @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -1164,19 +1133,14 @@ def test_decode_integrated_address(self, wallet: MoneroWallet) -> None: AssertUtils.assert_equals(integrated_address, decoded_address) # decode invalid address - try: + with pytest.raises(Exception) as exc_info: wallet.decode_integrated_address("bad address") - raise Exception("Should have failed decoding bad address") - except Exception as e: - WalletErrorUtils.test_invalid_address_error(e) + WalletErrorUtils.test_invalid_address_error(exc_info.value) # decode invalid payment id - try: + with pytest.raises(Exception) as exc_info: wallet.get_integrated_address(wallet.get_primary_address(), "invalid payment id") - raise Exception("Should have failed getting integrated address with invalid payment id") - except Exception as e: - e_msg: str = str(e) - assert e_msg == f"Invalid payment ID: invalid payment id", e_msg + assert str(exc_info.value) == "Invalid payment ID: invalid payment id" # Can sync (without progress) # TODO test syncing from start height @@ -1237,12 +1201,9 @@ def test_get_height_by_date(self, wallet: MoneroWallet) -> None: assert (height >= 0) # test future date - try: - tomorrow: datetime = datetime.fromtimestamp((yesterday + day_ms * 2) / 1000) + tomorrow: datetime = datetime.fromtimestamp((yesterday + day_ms * 2) / 1000) + with pytest.raises(MoneroError, match="specified date is in the future"): wallet.get_height_by_date(tomorrow.year + 1900, tomorrow.month + 1, tomorrow.day) - raise Exception("Expected exception on future date") - except MoneroError as err: - assert "specified date is in the future" == str(err) # Can get the locked and unlocked balances of the wallet, accounts and subaddresses @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -2236,15 +2197,12 @@ def test_validate_inputs_get_transfers(self, wallet: MoneroWallet) -> None: transfer_query.subaddress_indices.append(1234907) transfers = wallet.get_transfers(transfer_query) - # test unused subaddress index - try: - transfer_query = MoneroTransferQuery() - transfer_query.account_index = 0 + # test invalid subaddress index + transfer_query = MoneroTransferQuery() + transfer_query.account_index = 0 + with pytest.raises(Exception): transfer_query.subaddress_index = -1 - transfers = wallet.get_transfers(transfer_query) - raise Exception("Should have failed") - except Exception as e: - assert "Should have failed" != str(e) + wallet.get_transfers(transfer_query) # TODO Can get incoming and outgoing transfers using convenience methods @@ -2607,12 +2565,9 @@ def test_check_tx_key(self, wallet: MoneroWallet) -> None: TxWalletUtils.test_check_tx(tx, check) # test get tx key with invalid hash - try: + with pytest.raises(Exception) as exc_info: wallet.get_tx_key("invalid_tx_id") - raise Exception("Should throw exception for invalid key") - except Exception as e: - WalletErrorUtils.test_invalid_tx_hash_error(e) - + WalletErrorUtils.test_invalid_tx_hash_error(exc_info.value) # test check with invalid tx hash tx: MoneroTxWallet = txs[0] assert tx.hash is not None @@ -2620,26 +2575,17 @@ def test_check_tx_key(self, wallet: MoneroWallet) -> None: assert tx.outgoing_transfer is not None destination: MoneroDestination = tx.outgoing_transfer.destinations[0] assert destination.address is not None - try: + with pytest.raises(Exception) as exc_info: wallet.check_tx_key("invalid_tx_id", key, destination.address) - raise Exception("Should have thrown exception") - except Exception as e: - WalletErrorUtils.test_invalid_tx_hash_error(e) - + WalletErrorUtils.test_invalid_tx_hash_error(exc_info.value) # test check with invalid key - try: + with pytest.raises(Exception) as exc_info: wallet.check_tx_key(tx.hash, "invalid_tx_key", destination.address) - raise Exception("Should have thrown exception") - except Exception as e: - WalletErrorUtils.test_invalid_tx_key_error(e) - + WalletErrorUtils.test_invalid_tx_key_error(exc_info.value) # test check with invalid address - try: + with pytest.raises(Exception) as exc_info: wallet.check_tx_key(tx.hash, key, "invalid_tx_address") - raise Exception("Should have thrown exception") - except Exception as e: - WalletErrorUtils.test_invalid_address_error(e) - + WalletErrorUtils.test_invalid_address_error(exc_info.value) # test check with different address different_address: Optional[str] = None for a_tx in wallet.get_txs(): @@ -2695,26 +2641,17 @@ def test_check_tx_proof(self, wallet: MoneroWallet) -> None: TxWalletUtils.test_check_tx(tx, check) # test get proof with invalid hash - try: + with pytest.raises(Exception) as exc_info: wallet.get_tx_proof("invalid_tx_id", destination.address) - raise Exception("Should throw exception for invalid key") - except Exception as e: - WalletErrorUtils.test_invalid_tx_hash_error(e) - + WalletErrorUtils.test_invalid_tx_hash_error(exc_info.value) # test check tx proof with invalid tx hash - try: + with pytest.raises(Exception) as exc_info: wallet.check_tx_proof("invalid_tx_id", destination.address, '', signature) - raise Exception("Should have thrown exception") - except Exception as e: - WalletErrorUtils.test_invalid_tx_hash_error(e) - + WalletErrorUtils.test_invalid_tx_hash_error(exc_info.value) # test check with invalid address - try: + with pytest.raises(Exception) as exc_info: wallet.check_tx_proof(tx.hash, "invalid_tx_address", '', signature) - raise Exception("Should have throw exception") - except Exception as e: - WalletErrorUtils.test_invalid_address_error(e) - + WalletErrorUtils.test_invalid_address_error(exc_info.value) # test check with wrong message signature = wallet.get_tx_proof(tx.hash, destination.address, "This is the right message") check = wallet.check_tx_proof(tx.hash, destination.address, "This is the wrong message", signature) @@ -2773,19 +2710,13 @@ def test_check_spend_proof(self, wallet: MoneroWallet) -> None: assert result is True # test get proof with invalid hash - try: + with pytest.raises(Exception) as exc_info: wallet.get_spend_proof("invalid_tx_id") - raise Exception("Should throw exception for invalid key") - except Exception as e: - WalletErrorUtils.test_invalid_tx_hash_error(e) - + WalletErrorUtils.test_invalid_tx_hash_error(exc_info.value) # test check with invalid tx hash - try: + with pytest.raises(Exception) as exc_info: wallet.check_spend_proof("invalid_tx_id", '', signature) - raise Exception("Should have thrown exception") - except Exception as e: - WalletErrorUtils.test_invalid_tx_hash_error(e) - + WalletErrorUtils.test_invalid_tx_hash_error(exc_info.value) # test check with invalid message signature = wallet.get_spend_proof(tx.hash, "This is the right message") result = wallet.check_spend_proof(tx.hash, "This is the wrong message", signature) @@ -2818,21 +2749,15 @@ def test_get_reserve_proof_wallet(self, wallet: MoneroWallet) -> None: # test different wallet address different_address: str = WalletTestUtils.get_external_wallet_address() - try: + with pytest.raises(Exception) as exc_info: wallet.check_reserve_proof(different_address, "Test message", signature) - raise Exception("Should have thrown exception") - except Exception as e: - WalletErrorUtils.test_no_subaddress_error(e) - + WalletErrorUtils.test_no_subaddress_error(exc_info.value) # test subaddress - try: + with pytest.raises(Exception) as exc_info: address: Optional[str] = wallet.get_subaddress(0, 1).address assert address is not None wallet.check_reserve_proof(address, "Test message", signature) - raise Exception("Should have thrown exception") - except Exception as e: - WalletErrorUtils.test_no_subaddress_error(e) - + WalletErrorUtils.test_no_subaddress_error(exc_info.value) # test wrong message check = wallet.check_reserve_proof(wallet.get_primary_address(), "Wrong message", signature) # TODO: specifically test reserve checks, probably separate objects @@ -2840,12 +2765,9 @@ def test_get_reserve_proof_wallet(self, wallet: MoneroWallet) -> None: TxWalletUtils.test_check_reserve(check) # test wrong signature - try: + with pytest.raises(Exception) as exc_info: wallet.check_reserve_proof(wallet.get_primary_address(), "Test message", "wrong signature") - raise Exception("Should have thrown exception") - except Exception as e: - WalletErrorUtils.test_signature_header_error(e) - + WalletErrorUtils.test_signature_header_error(exc_info.value) # Can prove reserves in an account @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disablde") def test_get_reserve_proof_account(self, wallet: MoneroWallet) -> None: @@ -2867,65 +2789,40 @@ def test_get_reserve_proof_account(self, wallet: MoneroWallet) -> None: assert check.total_amount >= 0 num_non_zero_tests += 1 else: - try: + with pytest.raises(Exception) as exc_info: wallet.get_reserve_proof_account(account.index, account.balance, msg) - raise Exception("Should have thrown exception") - except Exception as e: - err_msg: str = str(e) - logger.debug(err_msg) - assert "Should have thrown exception" != err_msg, err_msg - - try: - wallet.get_reserve_proof_account(account.index, TxWalletUtils.MAX_FEE, msg) - raise Exception("Should have thrown exception") - except Exception as e: - err_msg: str = str(e) - logger.debug(err_msg) - assert "Should have thrown exception" != err_msg, err_msg + logger.debug(str(exc_info.value)) + + with pytest.raises(Exception) as exc_info: + wallet.get_reserve_proof_account(account.index, TxWalletUtils.MAX_FEE, msg) + logger.debug(str(exc_info.value)) assert num_non_zero_tests > 1, "Must have more than one account with non-zero balance; run send-to-multiple tests" # test error when not enough balance for requested minimum reserve amount + # TODO monero-project#6595: an over-balance reserve proof should be rejected but isn't + account: MoneroAccount = accounts[0] + assert account.balance is not None + amount: int = account.balance + TxWalletUtils.MAX_FEE try: - account: MoneroAccount = accounts[0] - assert account.balance is not None - amount: int = account.balance + TxWalletUtils.MAX_FEE proof: str = wallet.get_reserve_proof_account(0, amount, "Test message") reserve: MoneroCheckReserve = wallet.check_reserve_proof(wallet.get_primary_address(), "Test message", proof) - try: - wallet.get_reserve_proof_account(0, amount, "Test message") - raise Exception("expecting this to succeed") - except Exception as e: - err_msg: str = str(e) - assert "expecting this to succeed" == err_msg, err_msg - - logger.warning(f"Got reserve proof: {reserve.serialize()}") - raise Exception("Should have thrown exception but got reserve proof: https://github.com/monero-project/monero/issues/6595") + logger.warning(f"Got reserve proof despite insufficient balance: {reserve.serialize()}") except Exception as e: - err_msg: str = str(e) - logger.warning(err_msg) - #assert "Should have thrown exception" not in err_msg, err_msg + logger.debug(str(e)) # test different wallet address different_address: str = WalletTestUtils.get_external_wallet_address() - try: + with pytest.raises(Exception) as exc_info: wallet.check_reserve_proof(different_address, "Test message", signature) - raise Exception("Should have thrown exception") - except Exception as e: - err_msg: str = str(e) - logger.debug(err_msg) - assert "Should have thrown exception" != err_msg, err_msg + logger.debug(str(exc_info.value)) # test subaddress - try: - address: Optional[str] = wallet.get_subaddress(0, 1).address - assert address is not None + address: Optional[str] = wallet.get_subaddress(0, 1).address + assert address is not None + with pytest.raises(Exception) as exc_info: wallet.check_reserve_proof(address, "Test message", signature) - raise Exception("Should have thrown exception") - except Exception as e: - err_msg: str = str(e) - logger.debug(err_msg) - assert "Should have thrown exception" != err_msg, err_msg + logger.debug(str(exc_info.value)) # test wrong message check: MoneroCheckReserve = wallet.check_reserve_proof(wallet.get_primary_address(), "Wrong message", signature) @@ -2934,13 +2831,9 @@ def test_get_reserve_proof_account(self, wallet: MoneroWallet) -> None: TxWalletUtils.test_check_reserve(check) # test wrong signature - try: + with pytest.raises(Exception) as exc_info: wallet.check_reserve_proof(wallet.get_primary_address(), "Test message", "wrong signature") - raise Exception("Should have thrown exception") - except Exception as e: - err_msg: str = str(e) - logger.debug(err_msg) - assert "Should have thrown exception" != err_msg, err_msg + logger.debug(str(exc_info.value)) # Can get and set a transaction note @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -3003,7 +2896,6 @@ def test_export_key_images(self, wallet: MoneroWallet) -> None: # Can get new key images from the last import @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") - @pytest.mark.xfail(raises=Exception, reason="TODO these are already known to the wallet, so no new key images will be imported") def test_get_new_key_images_from_last_import(self, wallet: MoneroWallet) -> None: # get outputs hex outputs_hex: str = wallet.export_outputs() @@ -3014,11 +2906,10 @@ def test_get_new_key_images_from_last_import(self, wallet: MoneroWallet) -> None assert num_imported >= 0 # get and test new key images from last import - images: list[MoneroKeyImage] = wallet.get_new_key_images_from_last_import() - if len(images) == 0: - # TODO: these are already known to the wallet, so no new key images will be imported - raise Exception("No new key images in last import") - for image in images: + export_result: MoneroKeyImageExportResult = wallet.get_new_key_images_from_last_import() + if len(export_result.key_images) == 0: + pytest.skip("wallet already knows every output's key image; run after tests that generate new ones") + for image in export_result.key_images: assert image.hex is not None and len(image.hex) > 0 assert image.signature is not None and len(image.signature) > 0 @@ -3263,21 +3154,15 @@ def test_get_payment_uri(self, wallet: MoneroWallet) -> None: # test with undefined address address: str | None = config1.destinations[0].address config1.destinations[0].address = None - try: + with pytest.raises(Exception, match="Cannot make URI from supplied parameters"): wallet.get_payment_uri(config1) - raise Exception("Should have thrown RPC exception with invalid parameters") - except Exception as e: - assert "Cannot make URI from supplied parameters" in str(e), str(e) config1.destinations[0].address = address # test with standalone payment id config1.payment_id = "03284e41c342f03603284e41c342f03603284e41c342f03603284e41c342f036" - try: + with pytest.raises(Exception, match="Cannot make URI from supplied parameters"): wallet.get_payment_uri(config1) - raise Exception("Should have thrown RPC exception with invalid parameters") - except Exception as e: - assert "Cannot make URI from supplied parameters" in str(e), str(e) # Can start and stop mining @pytest.mark.skipif(TestUtils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @@ -3305,16 +3190,14 @@ def test_change_password(self) -> None: self._close_wallet(wallet) # old password does not work (password change is auto saved) - try: - config = MoneroWalletConfig() - config.path = path - config.password = TestUtils.WALLET_PASSWORD + config = MoneroWalletConfig() + config.path = path + config.password = TestUtils.WALLET_PASSWORD + with pytest.raises(Exception) as exc_info: self._open_wallet(config) - raise Exception("Should have thrown") - except Exception as e: - # TODO: different errors from rpc and wallet2 - e_str = str(e).lower() - assert "failed to open wallet" in e_str or "invalid password" in e_str, e_str + # TODO: different errors from rpc and wallet2 + e_str = str(exc_info.value).lower() + assert "failed to open wallet" in e_str or "invalid password" in e_str # open wallet with new password config = MoneroWalletConfig() @@ -3323,13 +3206,9 @@ def test_change_password(self) -> None: wallet = self._open_wallet(config) # change password with incorrect password - try: + with pytest.raises(Exception) as exc_info: wallet.change_password("badpassword", new_password) - raise Exception("Should have throw") - except Exception as e: - e_str = str(e) - assert "Invalid original password." == e_str, e_str - + assert "Invalid original password." == str(exc_info.value) # save and close self._close_wallet(wallet, True) @@ -3424,31 +3303,22 @@ def test_freeze_outputs(self, wallet: MoneroWallet) -> None: assert output.key_image.hex == output_frozen.key_image.hex # try to sweep frozen output - try: - tx_config: MoneroTxConfig = MoneroTxConfig() - tx_config.address = wallet.get_primary_address() - tx_config.key_image = output.key_image.hex + tx_config: MoneroTxConfig = MoneroTxConfig() + tx_config.address = wallet.get_primary_address() + tx_config.key_image = output.key_image.hex + with pytest.raises(Exception) as exc_info: wallet.sweep_output(tx_config) - raise Exception("Should have thrown error") - except Exception as e: - if "No outputs found" != str(e): - raise + assert str(exc_info.value) == "No outputs found" # try to freeze empty key image - try: + with pytest.raises(Exception) as exc_info: wallet.freeze_output("") - raise Exception("Should have thrown error") - except Exception as e: - if "Must specify key image to freeze" != str(e): - raise + assert str(exc_info.value) == "Must specify key image to freeze" # try to freeze bad key image - try: + with pytest.raises(Exception) as exc_info: wallet.freeze_output("123") - raise Exception("Should have thrown error") - except Exception as e: - if "failed to parse key image" != str(e): - raise + assert str(exc_info.value) == "failed to parse key image" # thaw output by key image wallet.thaw_output(output.key_image.hex) @@ -3701,22 +3571,16 @@ def test_account_tags(self, wallet: MoneroWallet) -> None: assert tagged_accounts[0].tag == tag.tag # untag and query accounts - err_msg: str = "Should have thrown exception with unregistered tag" wallet.untag_accounts([0, 1]) assert len(wallet.get_account_tags()) == 0 - try: + with pytest.raises(Exception): wallet.get_accounts(False, tag.tag) - raise Exception(err_msg) - except Exception as e: - e_msg: str = str(e) - assert e_msg != err_msg, e_msg - # test that non-existing tag returns no accounts + # a never-registered tag may raise or return nothing; either is acceptable try: - wallet.get_accounts(False, "non_existing_tag") + assert len(wallet.get_accounts(False, "non_existing_tag")) == 0 except Exception as e: - e_msg: str = str(e) - assert e_msg != err_msg, e_msg + logger.debug(f"get_accounts with non-existing tag raised: {e}") # endregion @@ -3778,8 +3642,10 @@ def test_create_and_receive(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) assert tx.is_failed is False, f"Tx failed in mempool: {tx.hash}" daemon.wait_for_next_block_header() - # receiver should have notified listeners of received outputs - sleep(TestUtils.SYNC_PERIOD_IN_MS * 10 / 1000) + # receiver should notify listeners of received outputs within a few rpc refresh / poll periods + deadline: float = monotonic() + TestUtils.SYNC_PERIOD_IN_MS * 10 / 1000 + while len(my_listener.outputs_received) == 0 and monotonic() < deadline: + sleep(1) assert len(my_listener.outputs_received) > 0 finally: logger.debug(f"Closing receiver wallet...") diff --git a/tests/test_monero_wallet_full.py b/tests/test_monero_wallet_full.py index 813de85..4701872 100644 --- a/tests/test_monero_wallet_full.py +++ b/tests/test_monero_wallet_full.py @@ -1,7 +1,5 @@ import pytest import logging -import subprocess -import sys from typing import Optional from typing_extensions import override @@ -11,11 +9,11 @@ MoneroWalletFull, MoneroWalletConfig, MoneroAccount, MoneroSubaddress, MoneroWallet, MoneroNetworkType, MoneroRpcConnection, MoneroUtils, MoneroDaemonRpc, - MoneroSyncResult, MoneroTxWallet + MoneroSyncResult, MoneroTxWallet, MoneroKeyImage ) from utils import ( - TestUtils as Utils, StringUtils, + TestUtils as Utils, StringUtils, BaseTestClass, AssertUtils, WalletUtils, WalletType, SyncSeedTester, SyncProgressTester, WalletEqualityUtils, WalletErrorUtils @@ -142,12 +140,9 @@ def test_create_wallet_random_full(self, daemon: MoneroDaemonRpc) -> None: assert wallet.get_restore_height() >= 0 # cannot get daemon chain height - try: + with pytest.raises(Exception) as exc_info: wallet.get_daemon_height() - raise Exception("Should have failed") - except Exception as e: - e_msg: str = str(e) - assert e_msg == "Wallet is not connected to daemon", e_msg + assert str(exc_info.value) == "Wallet is not connected to daemon" # set daemon and check chain height wallet.set_daemon_connection(daemon.get_rpc_connection()) @@ -202,11 +197,9 @@ def test_create_wallet_from_seed_full(self, daemon: MoneroDaemonRpc) -> None: assert wallet.is_synced() is False assert wallet.get_height() == 1 assert wallet.get_restore_height() == 0 - try: + with pytest.raises(Exception) as exc_info: wallet.start_syncing() - except Exception as e: - e_msg: str = str(e) - assert e_msg == "Wallet is not connected to daemon", e_msg + WalletErrorUtils.test_wallet_is_not_connected_error(exc_info.value) wallet.close() @@ -258,8 +251,7 @@ def test_create_wallet_from_seed_full(self, daemon: MoneroDaemonRpc) -> None: assert wallet.is_connected_to_daemon() is False assert wallet.is_synced() is False assert wallet.get_height() == 1 - # restore height is lost after closing - assert wallet.get_restore_height() == 0 + assert wallet.get_restore_height() == restore_height wallet.close() # create wallet with seed, connection, and restore height @@ -361,11 +353,9 @@ def test_sync_random(self, daemon: MoneroDaemonRpc) -> None: 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 + with pytest.raises(Exception) as exc_info: + wallet.sync() + assert str(exc_info.value) == "Wallet is not connected to daemon" finally: wallet.close() @@ -468,9 +458,9 @@ def test_start_stop_syncing(self, daemon: MoneroDaemonRpc) -> None: assert len(wallet.get_seed()) > 0 assert wallet.get_height() == 1 assert wallet.get_balance() == 0 - wallet.start_syncing() - except Exception as e: - WalletErrorUtils.test_wallet_is_not_connected_error(e) + with pytest.raises(Exception) as exc_info: + wallet.start_syncing() + WalletErrorUtils.test_wallet_is_not_connected_error(exc_info.value) finally: wallet.close() @@ -612,30 +602,25 @@ def test_close(self) -> None: assert wallet.is_closed() # attempt to interact with the wallet - try: + with pytest.raises(Exception) as exc_info: wallet.get_height() - except Exception as e: - WalletErrorUtils.test_wallet_is_closed_error(e) + WalletErrorUtils.test_wallet_is_closed_error(exc_info.value) - try: + with pytest.raises(Exception) as exc_info: wallet.get_seed() - except Exception as e: - WalletErrorUtils.test_wallet_is_closed_error(e) + WalletErrorUtils.test_wallet_is_closed_error(exc_info.value) - try: + with pytest.raises(Exception) as exc_info: wallet.sync() - except Exception as e: - WalletErrorUtils.test_wallet_is_closed_error(e) + WalletErrorUtils.test_wallet_is_closed_error(exc_info.value) - try: + with pytest.raises(Exception) as exc_info: wallet.start_syncing() - except Exception as e: - WalletErrorUtils.test_wallet_is_closed_error(e) + WalletErrorUtils.test_wallet_is_closed_error(exc_info.value) - try: + with pytest.raises(Exception) as exc_info: wallet.stop_syncing() - except Exception as e: - WalletErrorUtils.test_wallet_is_closed_error(e) + WalletErrorUtils.test_wallet_is_closed_error(exc_info.value) # re-open the wallet config = MoneroWalletConfig() @@ -668,60 +653,10 @@ def test_get_height_by_date(self, wallet: MoneroWallet) -> None: @pytest.mark.skipif(Utils.REGTEST is False, reason="REGTEST disabled") @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") - @pytest.mark.xfail(raises=RuntimeError, reason="Month or day out of range") def test_get_height_by_date_regtest(self, wallet: MoneroWallet) -> None: - return super().test_get_height_by_date(wallet) - - @pytest.mark.unit - @pytest.mark.xfail(reason="import_key_images() dereferences m_hex unconditionally (boost::optional UB when unset)", strict=True) - def test_import_key_images_hex_not_defined(self) -> None: - script: str = ( - "import monero, sys, tempfile, os\n" - "d = tempfile.mkdtemp()\n" - "cfg = monero.MoneroWalletConfig()\n" - "cfg.path = os.path.join(d, 'w')\n" - "cfg.password = 'testpass123'\n" - "cfg.network_type = monero.MoneroNetworkType.STAGENET\n" - "w = monero.MoneroWalletFull.create_wallet(cfg)\n" - "ki = monero.MoneroKeyImage()\n" - "try:\n" - " w.import_key_images([ki])\n" - " sys.exit('import_key_images() did not raise')\n" - "except RuntimeError as e:\n" - " sys.exit(0 if str(e) == 'key image hex is not defined' else f'wrong message: {e}')\n" - ) - result: subprocess.CompletedProcess[str] = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) - logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") - assert result.returncode == 0, ( - f"import_key_images() did not cleanly raise 'key image hex is not defined' " - f"(exit code {result.returncode}): {result.stderr.strip()[-300:]}" - ) - - @pytest.mark.unit - @pytest.mark.xfail(reason="import_key_images() dereferences m_signature unconditionally (boost::optional UB when unset)", strict=True) - def test_import_key_images_signature_not_defined(self) -> None: - script: str = ( - "import monero, sys, tempfile, os\n" - "d = tempfile.mkdtemp()\n" - "cfg = monero.MoneroWalletConfig()\n" - "cfg.path = os.path.join(d, 'w')\n" - "cfg.password = 'testpass123'\n" - "cfg.network_type = monero.MoneroNetworkType.STAGENET\n" - "w = monero.MoneroWalletFull.create_wallet(cfg)\n" - "ki = monero.MoneroKeyImage()\n" - "ki.hex = 'a' * 64\n" - "try:\n" - " w.import_key_images([ki])\n" - " sys.exit('import_key_images() did not raise')\n" - "except RuntimeError as e:\n" - " sys.exit(0 if str(e) == 'key image signature is not defined' else f'wrong message: {e}')\n" - ) - result: subprocess.CompletedProcess[str] = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) - logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") - assert result.returncode == 0, ( - f"import_key_images() did not cleanly raise 'key image signature is not defined' " - f"(exit code {result.returncode}): {result.stderr.strip()[-300:]}" - ) + # the base test's fixed dates fall outside a short regtest chain + with pytest.raises(RuntimeError): + super().test_get_height_by_date(wallet) #endregion @@ -753,3 +688,98 @@ def _test_sync_seed( tester.test() #endregion + + +@pytest.mark.unit +class TestMoneroWalletFullOffline(BaseTestClass): + """Full wallet unit tests that run against a disconnected wallet, no daemon needed.""" + + @pytest.fixture(scope="class") + def wallet(self) -> MoneroWalletFull: + """Shared disconnected full wallet.""" + return Utils.get_wallet_full_offline() + + # import_key_images guards unset key image fields + def test_import_key_images_hex_not_defined(self, wallet: MoneroWalletFull) -> None: + with pytest.raises(RuntimeError) as exc_info: + wallet.import_key_images([MoneroKeyImage()]) + assert str(exc_info.value) == "key image hex is not defined" + + def test_import_key_images_signature_not_defined(self, wallet: MoneroWalletFull) -> None: + key_image: MoneroKeyImage = MoneroKeyImage() + key_image.hex = "a" * 64 + with pytest.raises(RuntimeError) as exc_info: + wallet.import_key_images([key_image]) + assert str(exc_info.value) == "key image signature is not defined" + + # Can be moved + def test_move_to(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.path = "" + config.password = Utils.WALLET_PASSWORD + config.network_type = MoneroNetworkType.MAINNET + wallet: MoneroWalletFull = MoneroWalletFull.create_wallet(config) + try: + seed: str = wallet.get_seed() + wallet.set_attribute("mykey", "myval1") + + # move the in-memory wallet to disk + path1: str = Utils.get_random_wallet_path() + assert MoneroWalletFull.wallet_exists(path1) is False + wallet.move_to(path1, Utils.WALLET_PASSWORD) + assert MoneroWalletFull.wallet_exists(path1) is True + assert wallet.get_seed() == seed + assert wallet.get_attribute("mykey") == "myval1" + + # moving to the same path saves in place + wallet.set_attribute("mykey", "myval2") + wallet.move_to(path1, Utils.WALLET_PASSWORD) + wallet.close() + wallet = MoneroWalletFull.open_wallet(path1, Utils.WALLET_PASSWORD, MoneroNetworkType.MAINNET) + assert wallet.get_seed() == seed + assert wallet.get_attribute("mykey") == "myval2" + + # move to a new path + path2: str = Utils.get_random_wallet_path() + wallet.set_attribute("mykey", "myval3") + wallet.move_to(path2, Utils.WALLET_PASSWORD) + assert MoneroWalletFull.wallet_exists(path1) is False + assert MoneroWalletFull.wallet_exists(path2) is True + wallet.close() + wallet = MoneroWalletFull.open_wallet(path2, Utils.WALLET_PASSWORD, MoneroNetworkType.MAINNET) + assert wallet.get_seed() == seed + assert wallet.get_attribute("mykey") == "myval3" + finally: + wallet.close() + + # Can export and import wallet files + @pytest.mark.not_implemented + def test_export_and_import_wallet_files(self) -> None: + config: MoneroWalletConfig = MoneroWalletConfig() + config.path = "" + config.password = Utils.WALLET_PASSWORD + config.network_type = MoneroNetworkType.MAINNET + wallet: MoneroWalletFull = MoneroWalletFull.create_wallet(config) + from_keys: MoneroWalletFull | None = None + from_both: MoneroWalletFull | None = None + try: + keys_data: bytes = wallet.get_keys_file_buffer(Utils.WALLET_PASSWORD, False) + cache_data: bytes = wallet.get_cache_file_buffer() + assert len(keys_data) > 0 + assert len(cache_data) > 0 + + # open from the keys buffer alone, then from keys + cache + from_keys = MoneroWalletFull.open_wallet_data(Utils.WALLET_PASSWORD, MoneroNetworkType.MAINNET, keys_data, b"") + from_both = MoneroWalletFull.open_wallet_data(Utils.WALLET_PASSWORD, MoneroNetworkType.MAINNET, keys_data, cache_data) + + for restored in (from_keys, from_both): + assert restored.get_seed() == wallet.get_seed() + assert restored.get_primary_address() == wallet.get_primary_address() + assert restored.get_private_view_key() == wallet.get_private_view_key() + assert restored.get_private_spend_key() == wallet.get_private_spend_key() + finally: + wallet.close() + if from_keys is not None: + from_keys.close() + if from_both is not None: + from_both.close() diff --git a/tests/test_monero_wallet_interface.py b/tests/test_monero_wallet_interface.py index 92eb5fb..1e27128 100644 --- a/tests/test_monero_wallet_interface.py +++ b/tests/test_monero_wallet_interface.py @@ -66,6 +66,10 @@ def test_get_daemon_connection(self, wallet: MoneroWallet) -> None: def test_is_connected_to_daemon(self, wallet: MoneroWallet) -> None: wallet.is_connected_to_daemon() + @pytest.mark.not_supported + def test_is_daemon_synced(self, wallet: MoneroWallet) -> None: + wallet.is_daemon_synced() + @pytest.mark.not_supported def test_is_daemon_trusted(self, wallet: MoneroWallet) -> None: wallet.is_daemon_trusted() diff --git a/tests/test_monero_wallet_keys.py b/tests/test_monero_wallet_keys.py index f5f7bce..e422b1e 100644 --- a/tests/test_monero_wallet_keys.py +++ b/tests/test_monero_wallet_keys.py @@ -1,7 +1,5 @@ import pytest import logging -import subprocess -import sys from typing import Optional from typing_extensions import override @@ -94,8 +92,8 @@ def _open_wallet(self, config: Optional[MoneroWalletConfig]) -> MoneroWallet: @override def _close_wallet(self, wallet: MoneroWallet, save: bool = False) -> None: - # not supported by keys wallet - pass + # a keys-only wallet has nothing to persist, so `save` is ignored + wallet.close() @override def _get_seed_languages(self) -> list[str]: @@ -265,10 +263,11 @@ def test_get_subaddresses_by_indices(self, wallet: MoneroWallet) -> None: def test_create_subaddress(self, wallet: MoneroWallet) -> None: return super().test_create_subaddress(wallet) - @pytest.mark.xfail(raises=RuntimeError, reason="Keys-only wallet does not have enumerable set of subaddresses") @override def test_set_subaddress_label(self, wallet: MoneroWallet) -> None: - return super().test_set_subaddress_label(wallet) + # a keys-only wallet cannot enumerate its subaddresses, which this test needs + with pytest.raises(RuntimeError, match="does not have enumerable set of subaddresses"): + super().test_set_subaddress_label(wallet) @pytest.mark.not_supported @override @@ -370,10 +369,11 @@ def test_freeze_outputs(self, wallet: MoneroWallet) -> None: def test_get_outputs_with_query(self, wallet: MoneroWallet) -> None: return super().test_get_outputs_with_query(wallet) - @pytest.mark.xfail(raises=RuntimeError, reason="Keys-only wallet does not have enumerable set of subaddresses") @override def test_input_key_images(self, wallet: MoneroWallet) -> None: - return super().test_input_key_images(wallet) + # a keys-only wallet cannot enumerate accounts/subaddresses, which this test needs + with pytest.raises(RuntimeError, match=r"get_accounts\(\) not supported"): + super().test_input_key_images(wallet) @pytest.mark.not_supported @override @@ -597,14 +597,10 @@ def test_create_wallet_random(self) -> None: assert MoneroWallet.DEFAULT_LANGUAGE == wallet.get_seed_language() # attempt to create wallet with unknown language - try: - config = MoneroWalletConfig() - config.language = "english" + config = MoneroWalletConfig() + config.language = "english" + with pytest.raises(Exception, match="Unknown language: english"): 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 @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @override @@ -626,13 +622,11 @@ def test_create_wallet_from_seed(self, wallet: MoneroWallet, test_config: BaseTe assert MoneroWallet.DEFAULT_LANGUAGE == w.get_seed_language() # attempt to create wallet with two missing words - try: - config = MoneroWalletConfig() - config.seed = test_config.seed + config = MoneroWalletConfig() + config.seed = test_config.seed + with pytest.raises(Exception) as exc_info: self._create_wallet(config) - except Exception as e: - e_msg: str = str(e) - assert "Invalid mnemonic" == e_msg, e_msg + assert str(exc_info.value) == "Invalid mnemonic" @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @override @@ -650,7 +644,6 @@ def test_create_wallet_from_seed_with_offset(self) -> None: assert MoneroWallet.DEFAULT_LANGUAGE == wallet.get_seed_language() @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") - @pytest.mark.xfail(reason="TODO update to new monero-cpp") @override def test_create_wallet_from_keys(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None: # save for comparison @@ -685,24 +678,27 @@ def test_create_wallet_from_keys(self, daemon: MoneroDaemonRpc, wallet: MoneroWa config.private_view_key = private_view_key w = self._create_wallet(config) logger.info(f"Created wallet with config: {config.serialize()}") - logger.info(f"Wallet seed: {w.get_seed()}") + assert primary_address == w.get_primary_address() + assert private_view_key == w.get_private_view_key() assert w.get_network_type() == Utils.NETWORK_TYPE assert w.is_view_only() + # a view-only keys wallet has no seed + with pytest.raises(RuntimeError, match="watch-only"): + w.get_seed() assert not w.is_closed() w.close() @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") - @pytest.mark.xfail(raises=RuntimeError, reason="Neither a private spend key nor a private view key was supplied") def test_create_wallet_from_keys_no_keys(self) -> None: """ create_wallet_from_keys() must require at least one of the private spend/view keys. """ config: MoneroWalletConfig = MoneroWalletConfig() config.network_type = Utils.NETWORK_TYPE - MoneroWalletKeys.create_wallet_from_keys(config) + with pytest.raises(RuntimeError, match="Neither spend key nor view key supplied"): + MoneroWalletKeys.create_wallet_from_keys(config) @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") - @pytest.mark.xfail(raises=RuntimeError, reason="Malformed private spend key hex cannot be parsed") def test_create_wallet_from_keys_invalid_spend_key(self) -> None: """ create_wallet_from_keys() must fail to parse a malformed private spend key. @@ -710,10 +706,10 @@ def test_create_wallet_from_keys_invalid_spend_key(self) -> None: config: MoneroWalletConfig = MoneroWalletConfig() config.network_type = Utils.NETWORK_TYPE config.private_spend_key = "not-a-valid-hex-secret-key" - MoneroWalletKeys.create_wallet_from_keys(config) + with pytest.raises(RuntimeError, match="failed to parse secret spend key"): + MoneroWalletKeys.create_wallet_from_keys(config) @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") - @pytest.mark.xfail(raises=RuntimeError, reason="Malformed private view key hex cannot be parsed") def test_create_wallet_from_keys_invalid_view_key(self) -> None: """ create_wallet_from_keys() must fail to parse a malformed private view key. @@ -722,35 +718,21 @@ def test_create_wallet_from_keys_invalid_view_key(self) -> None: config.network_type = Utils.NETWORK_TYPE config.primary_address = Utils.ADDRESS config.private_view_key = "not-a-valid-hex-secret-key" - MoneroWalletKeys.create_wallet_from_keys(config) + with pytest.raises(RuntimeError, match="failed to parse secret view key"): + MoneroWalletKeys.create_wallet_from_keys(config) # Test invalid wallet configuration @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") - #@pytest.mark.xfail(reason="create_wallet_from_keys() dereferences m_primary_address unconditionally (boost::optional UB when unset)", strict=True) - @pytest.mark.skip("UB when m_primary_adress is unset") def test_create_wallet_from_keys_view_key_without_address(self) -> None: """ create_wallet_from_keys() must require a primary address when a private view key is given. - Non-deterministic in-process (observed locally as RuntimeError with varying messages - 'std::bad_alloc' or 'failed to parse address'). """ - script: str = ( - "import monero, sys\n" - "config = monero.MoneroWalletConfig()\n" - f"config.network_type = monero.MoneroNetworkType.{Utils.NETWORK_TYPE.name}\n" - "config.private_view_key = 'a' * 64\n" - "try:\n" - " monero.MoneroWalletKeys.create_wallet_from_keys(config)\n" - " sys.exit('create_wallet_from_keys() did not raise')\n" - "except RuntimeError as e:\n" - " sys.exit(0 if str(e) == 'must provide address if providing private view key' else f'wrong message: {e}')\n" - ) - result: subprocess.CompletedProcess[str] = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) - logger.debug(f"subprocess exit code: {result.returncode}, stderr: {result.stderr.strip()}") - assert result.returncode == 0, ( - f"create_wallet_from_keys() did not cleanly raise 'must provide address if providing " - f"private view key' (exit code {result.returncode}): {result.stderr.strip()[-300:]}" - ) + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + config.private_view_key = "a" * 64 + + with pytest.raises(RuntimeError, match="must provide address if providing private view key"): + MoneroWalletKeys.create_wallet_from_keys(config) @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @override @@ -843,6 +825,48 @@ def test_get_subaddress_by_index(self, wallet: MoneroWallet) -> None: subaddress, wallet.get_subaddresses(account.index, [subaddress.index])[0] ) + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_is_closed(self) -> None: + """A keys-only wallet reports its open/closed state and rejects use once closed.""" + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + + # a freshly created wallet is open + assert w.is_closed() is False + primary_address: str = w.get_primary_address() + + # closing it (without saving) marks it closed + w.close() + assert w.is_closed() is True + + # any accessor now raises "Wallet is closed" + with pytest.raises(RuntimeError, match="Wallet is closed"): + w.get_primary_address() + with pytest.raises(RuntimeError, match="Wallet is closed"): + w.get_private_view_key() + + # closing an already-closed wallet is a no-op (and does not re-raise) + w.close() + assert w.is_closed() is True + + # sanity: the address read before closing is unaffected + MoneroUtils.validate_address(primary_address, Utils.NETWORK_TYPE) + + @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") + def test_close_with_save_not_supported(self) -> None: + """`close(save=True)` is rejected because a keys-only wallet has nothing to persist.""" + config: MoneroWalletConfig = MoneroWalletConfig() + config.network_type = Utils.NETWORK_TYPE + w: MoneroWalletKeys = MoneroWalletKeys.create_wallet_random(config) + + with pytest.raises(RuntimeError, match="does not support saving"): + w.close(True) + + # the failed save-and-close left the wallet open + assert w.is_closed() is False + w.close() + #endregion #region Utils diff --git a/tests/test_monero_wallet_model.py b/tests/test_monero_wallet_model.py index 2751477..9986456 100644 --- a/tests/test_monero_wallet_model.py +++ b/tests/test_monero_wallet_model.py @@ -237,7 +237,6 @@ def test_account_deserialize(self) -> None: # reads it back (see test below) AssertUtils.assert_serialization_integrity(account) - @pytest.mark.xfail(reason="monero_account::from_property_tree() never reads back \"subaddresses\" even though to_rapidjson_val() emits it", strict=True) def test_account_subaddresses_deserialize(self) -> None: account: MoneroAccount = MoneroAccount() account.subaddresses = [MoneroSubaddress()] @@ -348,7 +347,6 @@ def test_tx_query_deserialize(self) -> None: # deserializing populates both copies at once (see test below) AssertUtils.assert_serialization_integrity(tx_query) - @pytest.mark.xfail(reason="monero_tx_query has monero_tx_wallet's fields of the same name, so from_property_tree() double-populates them", strict=True) def test_tx_query_is_incoming_deserialize_not_duplicated(self) -> None: tx_query: MoneroTxQuery = MoneroTxQuery() tx_query.is_incoming = True @@ -404,7 +402,6 @@ def test_tx_query_input_and_output_query_deserialize(self) -> None: assert tx_query.output_query.amount == 7 assert tx_query.output_query.index == 2 - @pytest.mark.xfail(reason="monero_tx_query::to_rapidjson_val() never serialized input_query/output_query", strict=True) def test_tx_query_input_and_output_query_serialize_round_trip(self) -> None: tx_query: MoneroTxQuery = MoneroTxQuery() tx_query.input_query = MoneroOutputQuery() @@ -505,7 +502,6 @@ def test_tx_set_deserialize(self) -> None: tx_set.multisig_tx_hex = "beefdead" AssertUtils.assert_serialization_integrity(tx_set) - @pytest.mark.xfail(reason="monero_tx_set::deserialize() bug", strict=True) def test_tx_set_signed_tx_hex_deserialize(self) -> None: tx_set: MoneroTxSet = MoneroTxSet() tx_set.signed_tx_hex = "deadbeef" @@ -539,7 +535,6 @@ def test_incoming_transfer_merge(self) -> None: a.merge(b) assert a.address == TestUtils.ADDRESS - @pytest.mark.xfail(reason="merge_incoming_transfer() dereferences account/subaddress index unconditionally (boost::optional UB when unset); locally this just dedups wrongly, but the same NDEBUG/ODR-ambiguity root cause aborts the process in CI", strict=True) def test_tx_wallet_merge_incoming_transfers_with_unset_indices_are_kept_distinct(self) -> None: """ merge_incoming_transfer() dedups incoming transfers by (account_index, subaddress_index) @@ -732,7 +727,6 @@ def test_tx_wallet_merge(self) -> None: a.merge(b) assert a.note == "hello" - @pytest.mark.xfail(reason="gen_utils::reconcile()'s bug", strict=True) def test_tx_wallet_merge_is_locked_can_become_false(self) -> None: a: MoneroTxWallet = MoneroTxWallet() a.hash = "a" * 64 @@ -743,7 +737,6 @@ def test_tx_wallet_merge_is_locked_can_become_false(self) -> None: a.merge(b) assert a.is_locked is False - @pytest.mark.xfail(reason="TODO monero-cpp bug", strict=True) def test_tx_wallet_outputs_deserialize_as_output_wallet(self) -> None: tx: MoneroTxWallet = MoneroTxWallet() tx.hash = "a" * 64 @@ -768,7 +761,6 @@ def test_tx_wallet_outputs_deserialize_as_output_wallet(self) -> None: assert restored.outputs[0].is_spent is True assert restored.outputs[0].is_frozen is False - @pytest.mark.xfail(reason="TODO monero-cpp bug", strict=True) def test_tx_wallet_get_outputs_wallet_after_deserialize(self) -> None: tx: MoneroTxWallet = MoneroTxWallet() tx.hash = "a" * 64 @@ -783,7 +775,6 @@ def test_tx_wallet_get_outputs_wallet_after_deserialize(self) -> None: assert len(outputs_wallet) == 1 assert outputs_wallet[0].amount == 500000 - @pytest.mark.xfail(reason="TODO monero-cpp fix monero_tx::copy()", strict=True) def test_tx_wallet_copy_preserves_output_wallet_type(self) -> None: tx: MoneroTxWallet = MoneroTxWallet() tx.hash = "a" * 64 diff --git a/tests/test_monero_wallet_rpc.py b/tests/test_monero_wallet_rpc.py index 402f669..c5c6574 100644 --- a/tests/test_monero_wallet_rpc.py +++ b/tests/test_monero_wallet_rpc.py @@ -85,10 +85,9 @@ def get_daemon_rpc_uri(self) -> str: @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") def test_offline_wallet(self) -> None: offline_wallet: MoneroWalletRpc = MoneroWalletRpc(Utils.OFFLINE_SERVER_URI, Utils.WALLET_RPC_USERNAME, Utils.WALLET_PASSWORD) - try: + with pytest.raises(Exception) as exc_info: offline_wallet.is_view_only() - except Exception as e: - WalletErrorUtils.test_wallet_is_not_connected_error(e) + WalletErrorUtils.test_wallet_is_not_connected_error(exc_info.value) @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") def test_sync_progress(self, wallet: MoneroWalletRpc) -> None: @@ -96,21 +95,15 @@ def test_sync_progress(self, wallet: MoneroWalletRpc) -> None: # expected error message ERR_MSG: str = "Monero Wallet RPC does not support reporting sync progress" - # try sync with listener - try: + # sync with listener + with pytest.raises(Exception) as exc_info: wallet.sync(listener) - raise Exception("Should have failed") - except Exception as e: - e_msg: str = str(e) - assert e_msg == ERR_MSG, e_msg + assert str(exc_info.value) == ERR_MSG - # try sync with listener from start height - try: + # sync with listener from start height + with pytest.raises(Exception) as exc_info: wallet.sync(0, listener) - raise Exception("Should have failed") - except Exception as e: - e_msg: str = str(e) - assert e_msg == ERR_MSG, e_msg + assert str(exc_info.value) == ERR_MSG @pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled") @override @@ -149,12 +142,10 @@ def test_create_wallet_random_rpc(self) -> None: MoneroUtils.validate_address(wallet.get_primary_address(), Utils.NETWORK_TYPE) # attempt to create wallet which already exists - try: + with pytest.raises(MoneroError) as exc_info: wallet.create_wallet(config) - except MoneroError as e: - err_msg: str = str(e) - assert err_msg == f"Wallet already exists: {path}", err_msg - assert seed == wallet.get_seed() + assert str(exc_info.value) == f"Wallet already exists: {path}" + assert seed == wallet.get_seed() self._close_wallet(wallet) @@ -234,15 +225,11 @@ def test_open_wallet(self)-> None: wallets.append(wallet) # attempt to open non-existent - try: - config: MoneroWalletConfig = MoneroWalletConfig() - config.path = "btc_integrity" - config.password = Utils.WALLET_PASSWORD + config: MoneroWalletConfig = MoneroWalletConfig() + config.path = "btc_integrity" + config.password = Utils.WALLET_PASSWORD + with pytest.raises(Exception): self._open_wallet(config) - raise Exception("Cannot open non-existent wallet") - except Exception as e: - e_msg: str = str(e) - assert e_msg != "Cannot open non-existent wallet", e_msg # close wallets: for wallet in wallets: @@ -274,20 +261,17 @@ def test_close(self, daemon: MoneroDaemonRpc) -> None: Utils.free_wallet_rpc_resource(wallet) # attempt to interact with the wallet - try: + with pytest.raises(Exception) as exc_info: wallet.get_height() - except Exception as e: - WalletErrorUtils.test_no_wallet_file_error(e) + WalletErrorUtils.test_no_wallet_file_error(exc_info.value) - try: + with pytest.raises(Exception) as exc_info: wallet.get_seed() - except Exception as e: - WalletErrorUtils.test_no_wallet_file_error(e) + WalletErrorUtils.test_no_wallet_file_error(exc_info.value) - try: + with pytest.raises(Exception) as exc_info: wallet.sync() - except Exception as e: - WalletErrorUtils.test_no_wallet_file_error(e) + WalletErrorUtils.test_no_wallet_file_error(exc_info.value) # re-open the wallet wallet.open_wallet(path, Utils.WALLET_PASSWORD) diff --git a/tests/utils/block_utils.py b/tests/utils/block_utils.py index 29450e0..f46393d 100644 --- a/tests/utils/block_utils.py +++ b/tests/utils/block_utils.py @@ -64,6 +64,8 @@ def test_full_header(cls, header: MoneroBlockHeader, is_full: Optional[bool]) -> # num_txs always defined assert header.num_txs is not None assert header.num_txs >= 0 + assert header.hash is not None + assert len(header.hash) == 64 if is_full: # check full block @@ -73,7 +75,6 @@ def test_full_header(cls, header: MoneroBlockHeader, is_full: Optional[bool]) -> assert header.difficulty_high is not None assert header.cumulative_difficulty_low is not None assert header.cumulative_difficulty_high is not None - assert header.hash is not None assert header.miner_tx_hash is not None assert header.weight is not None assert header.size > 0 @@ -95,7 +96,6 @@ def test_full_header(cls, header: MoneroBlockHeader, is_full: Optional[bool]) -> assert header.difficulty_high is None assert header.cumulative_difficulty_low is None assert header.cumulative_difficulty_high is None - assert header.hash is None assert header.miner_tx_hash is None assert header.orphan_status is None assert header.reward is None @@ -217,7 +217,6 @@ def test_block_template(cls, template: MoneroBlockTemplate) -> None: assert template.prev_hash is not None assert template.reserved_offset is not None assert template.seed_height is not None - assert template.seed_height is not None assert template.seed_height >= 0 assert template.seed_hash is not None assert len(template.seed_hash) > 0 diff --git a/tests/utils/daemon_utils.py b/tests/utils/daemon_utils.py index 4781944..a05b312 100644 --- a/tests/utils/daemon_utils.py +++ b/tests/utils/daemon_utils.py @@ -6,8 +6,8 @@ MoneroConnectionSpan, MoneroHardForkInfo, MoneroBlock, MoneroBan, MoneroMinerTxSum, MoneroTx, MoneroTxPoolStats, MoneroDaemonUpdateCheckResult, MoneroDaemonUpdateDownloadResult, - MoneroNetworkType, MoneroSubmitTxResult, - MoneroKeyImageSpentStatus, MoneroDaemonRpc, + MoneroNetworkType, MoneroSubmitTxResult, MoneroKeyImageSpentStatus, + MoneroDaemonRpc, MoneroMinerData, MoneroDaemonNetworkStats ) from .gen_utils import GenUtils @@ -161,6 +161,7 @@ def test_info(cls, info: MoneroDaemonInfo) -> None: assert len(info.top_block_hash) > 0 assert info.is_busy_syncing is not None assert info.is_synchronized is not None + assert info.is_regtest is not None @classmethod def test_connection_span(cls, span: MoneroConnectionSpan) -> None: @@ -227,6 +228,37 @@ def test_ban(cls, ban: MoneroBan) -> None: assert ban.ip is not None assert ban.seconds is not None + @classmethod + def test_miner_data(cls, data: MoneroMinerData) -> None: + """Test daemon miner data. + + :param MoneroMinerData data: miner data to test. + """ + logger.debug(f"Testing miner data: {data.serialize()}") + assert data.major_version is not None and data.major_version > 0 + assert data.height is not None and data.height > 0 + assert data.prev_hash is not None and len(data.prev_hash) == 64 + assert data.seed_hash is not None and len(data.seed_hash) == 64 + # difficulty comes back as a hex string, e.g. "0x1f4" + assert data.difficulty is not None and int(data.difficulty, 0) > 0 + assert data.median_weight is not None and data.median_weight >= 0 + assert data.already_generated_coins is not None and data.already_generated_coins > 0 + for tx in data.tx_pool_backlog: + assert tx.hash is not None and len(tx.hash) == 64 + + @classmethod + def test_network_stats(cls, stats: MoneroDaemonNetworkStats) -> None: + """Test daemon network statistics. + + :param MoneroDaemonNetworkStats stats: network statistics to test. + """ + logger.debug(f"Testing network stats: {stats.serialize()}") + assert stats.start_time is not None and stats.start_time > 0 + assert stats.total_packets_in is not None and stats.total_packets_in >= 0 + assert stats.total_bytes_in is not None and stats.total_bytes_in >= 0 + assert stats.total_packets_out is not None and stats.total_packets_out >= 0 + assert stats.total_bytes_out is not None and stats.total_bytes_out >= 0 + @classmethod def test_miner_tx_sum(cls, tx_sum: MoneroMinerTxSum) -> None: """Test miner tx sum result. diff --git a/tests/utils/rpc_connection_utils.py b/tests/utils/rpc_connection_utils.py index ff1b83b..ba4eb48 100644 --- a/tests/utils/rpc_connection_utils.py +++ b/tests/utils/rpc_connection_utils.py @@ -1,5 +1,7 @@ import logging +import pytest + from abc import ABC from monero import SerializableStruct, MoneroRpcConnection, MoneroConnectionType @@ -70,13 +72,9 @@ def test_rpc_connection( # test check connection cls.test_check_rpc_connection(connection, connected) - # test setting to readonly property - try: + # response_time is a read-only property + with pytest.raises(Exception): connection.response_time = 0 # type: ignore - raise Exception("Should have failed") - except Exception as e: - e_msg: str = str(e) - assert e_msg != "Should have failed", e_msg # test connection type if connection_type == MoneroConnectionType.I2P: diff --git a/tests/utils/single_tx_sender.py b/tests/utils/single_tx_sender.py index 9abe4d9..b356fe9 100644 --- a/tests/utils/single_tx_sender.py +++ b/tests/utils/single_tx_sender.py @@ -1,5 +1,7 @@ import logging +import pytest + from typing import Optional from monero import ( MoneroWallet, MoneroTxConfig, MoneroAccount, @@ -166,37 +168,27 @@ def _send_to_invalid(self, config: MoneroTxConfig) -> None: :param MoneroTxConfig config: tx configuration. """ - # save original address max_retries: int = 3 - num_retries: int = 0 - while True: - logger.debug(f"Trying sending to invalid address ({num_retries + 1}/{max_retries})...") + for attempt in range(1, max_retries + 2): + logger.debug(f"Trying sending to invalid address ({attempt}/{max_retries + 1})...") + config.set_address("my invalid address") try: - # set invalid destination address - config.set_address("my invalid address") - # create tx - if config.can_split is not False: - self._wallet.create_txs(config) - else: - self._wallet.create_tx(config) - # raise error - raise Exception("Should have thrown error creating tx with invalid address") - except Exception as e: - # retry on network error - msg: str = str(e) - if msg == "Network error": - if num_retries == max_retries: - raise - num_retries += 1 - continue - - assert msg == "Invalid destination address", msg - break + with pytest.raises(Exception) as exc_info: + if config.can_split is not False: + self._wallet.create_txs(config) + else: + self._wallet.create_tx(config) finally: - # restore original address config.set_address(self.address) + # retry on transient network error + if str(exc_info.value) == "Network error" and attempt <= max_retries: + continue + + assert str(exc_info.value) == "Invalid destination address" + return + def _send_to_self(self, config: MoneroTxConfig) -> list[MoneroTxWallet]: """Test sending to self. diff --git a/tests/utils/sync_progress_tester.py b/tests/utils/sync_progress_tester.py index bb109f6..536e6d0 100644 --- a/tests/utils/sync_progress_tester.py +++ b/tests/utils/sync_progress_tester.py @@ -11,14 +11,16 @@ class SyncProgressTester(WalletSyncPrinter): 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_height: Optional[int] + """Previous notified blockchain height.""" prev_complete_height: Optional[int] - """Previous blockchain completed height.""" + """End height of the last completed sync session.""" + session_start: bool + """`True` while the next notification is the first of a sync session.""" is_done: bool """Indicates if wallet sync is completed.""" on_sync_progress_after_done: Optional[bool] @@ -45,6 +47,7 @@ def __init__(self, wallet: MoneroWalletFull, start_height: int, end_height: int) assert end_height >= 0, f"Invalid end height provided: {end_height}" self.start_height = start_height self.prev_end_height = end_height + self.session_start = True self.is_done = False self.prev_height = None @@ -69,29 +72,32 @@ def on_sync_progress(self, height: int, start_height: int, end_height: int, perc 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: + if self.session_start and 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 - + # progress notifications are throttled, so heights may skip, and the start height may rebase + # down to report progress while the wallet skips hashes below the sync start assert end_height > start_height, "end height > start height" - assert self.start_height == start_height - assert end_height >= start_height - assert height < end_height + assert start_height <= self.start_height, "start height only rebases down" + self.start_height = start_height + assert end_height >= self.prev_end_height, "chain can only grow while syncing" + self.prev_end_height = end_height + if self.prev_height is not None: + assert height >= self.prev_height, "heights advance monotonically" + self.prev_height = 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 + if height < start_height: + assert self.session_start, "hash-skip notification only at the start of a sync session" + assert percent_done == 0.0 # initial notification while the wallet skips ahead to the sync start else: - assert height == self.prev_height + 1 + assert height < end_height + expected_percent_done: float = (height - start_height + 1) / (end_height - start_height) + assert expected_percent_done == percent_done + if percent_done == 1.0: + self.prev_complete_height = end_height # record completion height for subsequent sync sessions - self.prev_height = height + # completion starts a new session + self.session_start = percent_done == 1.0 def on_done(self, chain_height: int) -> None: """Called once on sync progress done. @@ -104,6 +110,6 @@ def on_done(self, chain_height: int) -> None: assert self.prev_complete_height is None assert chain_height == self.start_height else: - # otherwise last height is chain height - 1 + # otherwise the last progress notification reports the final block 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 index 6cfad78..47352b0 100644 --- a/tests/utils/sync_seed_tester.py +++ b/tests/utils/sync_seed_tester.py @@ -2,7 +2,7 @@ from typing import Optional, Callable -from time import sleep +from time import sleep, monotonic from monero import ( MoneroDaemonRpc, MoneroWalletFull, MoneroWalletConfig, MoneroSyncResult, MoneroTxWallet @@ -84,9 +84,13 @@ def test_post_sync(self, wallet: MoneroWalletFull, wallet_sync_tester: WalletSyn self.daemon.wait_for_next_block_header() # ensure wallet has time to detect new block - sleep((TestUtils.SYNC_PERIOD_IN_MS / 1000) + 3) + deadline: float = monotonic() + max(6 * TestUtils.SYNC_PERIOD_IN_MS / 1000, 60) + while monotonic() < deadline: + if wallet_sync_tester.on_sync_progress_after_done and wallet_sync_tester.on_new_block_after_done: + break + sleep(1) - # test that wallet listener's onSyncProgress() and onNewBlock() were invoked after previous completion + # test that wallet listener's on_sync_progress() and on_new_block() were invoked after previous completion assert wallet_sync_tester.on_sync_progress_after_done assert wallet_sync_tester.on_new_block_after_done finally: @@ -146,6 +150,11 @@ def test_notifications(self, wallet: MoneroWalletFull, start_height_expected: in # block might be added to chain assert result.num_blocks_fetched == 0 or result.num_blocks_fetched == 1 assert result.received_money is False + if wallet.get_restore_height() > wallet.get_height(): + logger.warning( + f"restore height {wallet.get_restore_height()} > wallet height {wallet.get_height()} " + "after sync: wallet will re-scan on every sync" + ) # compare with ground truth if not self.skip_gt_comparison: diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index ab2bb16..fd425b7 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -37,6 +37,8 @@ class TestUtils(ABC): # objects cache _WALLET_FULL: Optional[MoneroWalletFull] = None """Default wallet full used for tests.""" + _WALLET_FULL_OFFLINE: Optional[MoneroWalletFull] = None + """Disconnected wallet full for tests that don't need a daemon.""" _WALLET_KEYS: Optional[MoneroWalletKeys] = None """Default wallet keys used for tests.""" _WALLET_RPC: Optional[MoneroWalletRpc] = None @@ -161,6 +163,8 @@ class TestUtils(ABC): LOG_LEVEL: int = 4 """Monero core internal log level.""" + DAEMON_LOG_LEVEL: int = 3 + """Daemon rpc log level.""" @classmethod def load_config(cls) -> None: @@ -184,6 +188,7 @@ def load_config(cls) -> None: cls.LITE_MODE = parser.getboolean('general', 'lite_mode') cls.TEST_RESETS = parser.getboolean('general', 'test_resets') cls.AUTO_CONNECT_TIMEOUT_MS = parser.getint('general', 'auto_connect_timeout_ms') + cls.LOG_LEVEL = parser.getint('general', 'log_level', fallback=cls.LOG_LEVEL) cls.NETWORK_TYPE = DaemonUtils.parse_network_type(nettype_str) cls.REGTEST = DaemonUtils.is_regtest(nettype_str) @@ -191,6 +196,7 @@ def load_config(cls) -> None: cls.MIN_BLOCK_HEIGHT = 100 # minimum block height for regtest environment # parse daemon config + cls.DAEMON_LOG_LEVEL = parser.getint('daemon', 'log_level', fallback=cls.DAEMON_LOG_LEVEL) cls.DAEMON_RPC_URI = parser.get('daemon', 'rpc_uri') cls.CONTAINER_DAEMON_RPC_URI = cls.DAEMON_RPC_URI.replace("127.0.0.1", "node_2") cls.DAEMON_RPC_USERNAME = parser.get('daemon', 'rpc_username') @@ -410,6 +416,20 @@ def get_wallet_full(cls) -> MoneroWalletFull: assert cls.ADDRESS == cls._WALLET_FULL.get_primary_address() return cls._WALLET_FULL + @classmethod + def get_wallet_full_offline(cls) -> MoneroWalletFull: + """Get a shared in-memory full wallet with no daemon connection. + + :returns MoneroWalletFull: disconnected full test wallet. + """ + if cls._WALLET_FULL_OFFLINE is None or cls._WALLET_FULL_OFFLINE.is_closed(): + config: MoneroWalletConfig = MoneroWalletConfig() + config.path = "" + config.password = cls.WALLET_PASSWORD + config.network_type = cls.NETWORK_TYPE + cls._WALLET_FULL_OFFLINE = MoneroWalletFull.create_wallet(config) + return cls._WALLET_FULL_OFFLINE + @classmethod def get_mining_wallet_config(cls) -> MoneroWalletConfig: """Get mining wallet configuration. diff --git a/tests/utils/wallet_sync_tester.py b/tests/utils/wallet_sync_tester.py index 6477a3f..93529d0 100644 --- a/tests/utils/wallet_sync_tester.py +++ b/tests/utils/wallet_sync_tester.py @@ -16,6 +16,8 @@ class WalletSyncTester(SyncProgressTester): wallet_tester_prev_height: Optional[int] """Renamed from `prev_height` to not interfere with super's `prev_height`.""" + sync_start_height: int + """Requested sync start height; unlike super's `start_height` it is not rebased by progress notifications.""" prev_output_received: Optional[MoneroOutputWallet] """Previous notified output received.""" prev_output_spent: Optional[MoneroOutputWallet] @@ -41,6 +43,7 @@ def __init__(self, wallet: MoneroWalletFull, start_height: int, end_height: int) super().__init__(wallet, start_height, end_height) assert start_height >= 0 assert end_height >= 0 + self.sync_start_height = start_height self.incoming_total = 0 self.outgoing_total = 0 @@ -64,7 +67,8 @@ def on_new_block(self, height: int) -> None: if self.wallet_tester_prev_height is not None: assert self.wallet_tester_prev_height + 1 == height - assert height >= self.start_height + # scanned blocks start at the requested height, not the rebased progress base + assert height >= self.sync_start_height self.wallet_tester_prev_height = height @override diff --git a/tests/utils/wallet_utils.py b/tests/utils/wallet_utils.py index 197ac43..506c34c 100644 --- a/tests/utils/wallet_utils.py +++ b/tests/utils/wallet_utils.py @@ -1,5 +1,7 @@ import logging +import pytest + from abc import ABC from typing import Optional @@ -36,12 +38,8 @@ def test_invalid_address(cls, address: Optional[str], network_type: MoneroNetwor assert MoneroUtils.is_valid_address(address, network_type) is False - try: + with pytest.raises(Exception): MoneroUtils.validate_address(address, network_type) - raise Exception("Should have thrown exception") - except Exception as e: - e_msg: str = str(e) - assert "Should have thrown exception" != e_msg, e_msg @classmethod def test_invalid_private_view_key(cls, private_view_key: Optional[str]) -> None: @@ -54,12 +52,8 @@ def test_invalid_private_view_key(cls, private_view_key: Optional[str]) -> None: assert MoneroUtils.is_valid_private_view_key(private_view_key) is False - try: + with pytest.raises(Exception): MoneroUtils.validate_private_view_key(private_view_key) - raise Exception("Should have thrown exception") - except Exception as e: - e_msg: str = str(e) - assert "Should have thrown exception" != e_msg, e_msg @classmethod def test_invalid_public_view_key(cls, public_view_key: Optional[str]) -> None: @@ -72,12 +66,8 @@ def test_invalid_public_view_key(cls, public_view_key: Optional[str]) -> None: assert MoneroUtils.is_valid_public_view_key(public_view_key) is False - try: + with pytest.raises(Exception): MoneroUtils.validate_public_view_key(public_view_key) - raise Exception("Should have thrown exception") - except Exception as e: - e_msg: str = str(e) - assert "Should have thrown exception" != e_msg, e_msg @classmethod def test_invalid_private_spend_key(cls, private_spend_key: Optional[str]) -> None: @@ -90,12 +80,8 @@ def test_invalid_private_spend_key(cls, private_spend_key: Optional[str]) -> Non assert MoneroUtils.is_valid_private_spend_key(private_spend_key) is False - try: + with pytest.raises(Exception): MoneroUtils.validate_private_spend_key(private_spend_key) - raise Exception("Should have thrown exception") - except Exception as e: - e_msg: str = str(e) - assert "Should have thrown exception" != e_msg, e_msg @classmethod def test_invalid_public_spend_key(cls, public_spend_key: Optional[str]) -> None: @@ -107,12 +93,8 @@ def test_invalid_public_spend_key(cls, public_spend_key: Optional[str]) -> None: return assert MoneroUtils.is_valid_public_spend_key(public_spend_key) is False - try: + with pytest.raises(Exception): MoneroUtils.validate_public_spend_key(public_spend_key) - raise Exception("Should have thrown exception") - except Exception as e: - e_msg: str = str(e) - assert "Should have thrown exception" != e_msg, e_msg @classmethod def test_account(cls, account: Optional[MoneroAccount], network_type: MoneroNetworkType, full: bool = True) -> None: